| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- import React, { useState, useEffect } from 'react';
- import Head from 'next/head';
- import { useRouter } from 'next/router';
- import MagicalGirlCardACG from '../components/MagicalGirlCardACG';
- import { useCooldown } from '../lib/cooldown';
- import { quickCheck } from '@/lib/sensitive-word-filter';
- interface Questionnaire {
- questions: string[];
- }
- interface MagicalGirlACG {
- codename: string;
- appearance: {
- outfit: string;
- accessories: string;
- colorScheme: string;
- overallLook: string;
- };
- magicWeapon: {
- name: string;
- form: string;
- basicAbilities: string[];
- description: string;
- };
- specialMove: {
- name: string;
- chant: string;
- description: string;
- };
- awakening: {
- name: string;
- evolvedAbilities: string[];
- evolvedForm: string;
- evolvedOutfit: string;
- };
- analysis: {
- personalityAnalysis: string;
- abilityReasoning: string;
- coreTraits: string[];
- predictionBasis: string;
- };
- }
- const SaveJsonButton: React.FC<{ magicalGirlDetails: MagicalGirlACG; answers: string[] }> = ({ magicalGirlDetails, answers }) => {
- const [isMobile, setIsMobile] = useState(false);
- const [showJsonText, setShowJsonText] = useState(false);
- useEffect(() => {
- const userAgent = navigator.userAgent.toLowerCase();
- const isMobileDevice = /mobile|android|iphone|ipad|ipod|blackberry|iemobile|opera mini/.test(userAgent);
- setIsMobile(isMobileDevice);
- }, []);
- const downloadJson = () => {
- const dataToSave = {
- ...magicalGirlDetails,
- userAnswers: answers
- };
- const jsonData = JSON.stringify(dataToSave, null, 2);
- const blob = new Blob([jsonData], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `魔法少女_${magicalGirlDetails.codename || 'data'}.json`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- };
- const handleSave = () => {
- if (isMobile) {
- setShowJsonText(true);
- } else {
- downloadJson();
- }
- };
- if (showJsonText) {
- return (
- <div className="text-left">
- <div className="mb-4 text-center">
- <p className="text-sm text-gray-600 mb-2">请复制以下数据并保存</p>
- <button
- onClick={() => setShowJsonText(false)}
- className="text-blue-600 text-sm"
- >
- 返回
- </button>
- </div>
- <textarea
- value={JSON.stringify({ ...magicalGirlDetails, userAnswers: answers }, null, 2)}
- readOnly
- className="w-full h-64 p-3 border rounded-lg text-xs font-mono bg-gray-50 text-gray-900"
- onClick={(e) => (e.target as HTMLTextAreaElement).select()}
- />
- <p className="text-xs text-gray-500 mt-2 text-center">点击文本框可全选内容</p>
- </div>
- );
- }
- return (
- <button
- onClick={handleSave}
- className="generate-button"
- >
- {isMobile ? '查看原始数据' : '下载设定文件'}
- </button>
- );
- };
- const ACGPage: React.FC = () => {
- const router = useRouter();
- const [questions, setQuestions] = useState<string[]>([]);
- const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
- const [answers, setAnswers] = useState<string[]>([]);
- const [currentAnswer, setCurrentAnswer] = useState('');
- const [loading, setLoading] = useState(true);
- const [submitting, setSubmitting] = useState(false);
- const [isTransitioning, setIsTransitioning] = useState(false);
- const [magicalGirlDetails, setMagicalGirlDetails] = useState<MagicalGirlACG | null>(null);
- const [showImageModal, setShowImageModal] = useState(false);
- const [savedImageUrl, setSavedImageUrl] = useState<string | null>(null);
- const [showIntroduction, setShowIntroduction] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [showDetails, setShowDetails] = useState(false);
- const { isCooldown, startCooldown, remainingTime } = useCooldown('generateDetailsCooldown', 60000);
- useEffect(() => {
- fetch('/acg_questionnaire.json')
- .then(response => {
- if (!response.ok) {
- throw new Error('加载问卷文件失败');
- }
- return response.json();
- })
- .then((data: Questionnaire) => {
- setQuestions(data.questions);
- setAnswers(new Array(data.questions.length).fill(''));
- setLoading(false);
- })
- .catch(error => {
- console.error('加载问卷失败:', error);
- setError('📋 加载问卷失败,请刷新页面重试');
- setLoading(false);
- });
- }, []);
- const handleNext = () => {
- if (currentAnswer.trim().length === 0) {
- setError('⚠️ 请输入答案后再继续');
- return;
- }
- if (currentAnswer.length > 30) {
- setError('⚠️ 答案不能超过30字');
- return;
- }
- setError(null);
- proceedToNextQuestion(currentAnswer.trim());
- };
- const handleQuickOption = (option: string) => {
- proceedToNextQuestion(option);
- };
- const proceedToNextQuestion = (answer: string) => {
- const newAnswers = [...answers];
- newAnswers[currentQuestionIndex] = answer;
- setAnswers(newAnswers);
- if (currentQuestionIndex < questions.length - 1) {
- setIsTransitioning(true);
- setTimeout(() => {
- setCurrentQuestionIndex(currentQuestionIndex + 1);
- setCurrentAnswer(newAnswers[currentQuestionIndex + 1] || '');
- setTimeout(() => {
- setIsTransitioning(false);
- }, 50);
- }, 250);
- } else {
- handleSubmit(newAnswers);
- }
- };
- const handleSubmit = async (finalAnswers: string[]) => {
- if (isCooldown) {
- setError(`请等待 ${remainingTime} 秒后再生成`);
- return;
- }
- setSubmitting(true);
- setError(null);
- const result = await quickCheck(finalAnswers.join(''));
- if (result.hasSensitiveWords) {
- router.push('/arrested');
- return;
- }
- try {
- const response = await fetch('/api/generate-magical-girl-acg', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ answers: finalAnswers })
- });
- if (!response.ok) {
- const errorData = await response.json();
- if (response.status === 429) {
- const retryAfter = errorData.retryAfter || 60;
- throw new Error(`请求过于频繁!请等待 ${retryAfter} 秒后再试。`);
- } else if (response.status >= 500) {
- throw new Error('服务器内部错误,请稍后重试');
- } else {
- throw new Error(errorData.message || errorData.error || '生成失败');
- }
- }
- const result: MagicalGirlACG = await response.json();
- setMagicalGirlDetails(result);
- setError(null);
- } catch (error) {
- if (error instanceof Error) {
- const errorMessage = error.message;
- if (errorMessage.includes('请求过于频繁')) {
- setError('🚫 请求太频繁了!每2分钟只能生成一次哦~请稍后再试吧!');
- } else if (errorMessage.includes('网络') || error instanceof TypeError) {
- setError('🌐 网络连接有问题!请检查网络后重试~');
- } else {
- setError(`✨ 魔法失效了!${errorMessage}`);
- }
- } else {
- setError('✨ 魔法失效了!生成详情时发生未知错误,请重试');
- }
- } finally {
- setSubmitting(false);
- startCooldown();
- }
- };
- const handleSaveImage = (imageUrl: string) => {
- setSavedImageUrl(imageUrl);
- setShowImageModal(true);
- };
- const handleStartQuestionnaire = () => {
- setShowIntroduction(false);
- };
- if (loading) {
- return (
- <div className="magic-background">
- <div className="container">
- <div className="card">
- <div className="text-center text-lg">加载中...</div>
- </div>
- </div>
- </div>
- );
- }
- if (questions.length === 0) {
- return (
- <div className="magic-background">
- <div className="container">
- <div className="card">
- <div className="error-message">加载问卷失败</div>
- </div>
- </div>
- </div>
- );
- }
- const isLastQuestion = currentQuestionIndex === questions.length - 1;
- return (
- <>
- <Head>
- <title>魔法少女调查问卷 ~ 二次元篇</title>
- <meta name="description" content="回答问卷,生成您的专属二次元魔法少女" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <link rel="icon" href="/favicon.ico" />
- </Head>
- <div className="magic-background">
- <div className="container">
- <div className="card">
- <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '1rem' }}>
- <img src="/questionnaire-logo.svg" width={250} height={160} alt="Questionnaire Logo" />
- </div>
- {showIntroduction ? (
- <div className="text-center">
- <div className="mb-6 leading-relaxed text-gray-800"
- style={{ lineHeight: '1.5', marginTop: '3rem', marginBottom: '4rem' }}
- >
- 回答这些问题,唤醒你心中沉睡的魔法少女之魂!<br />
- <p style={{ fontSize: '0.8rem', marginTop: '1rem', color: '#999', fontStyle: 'italic' }}>本测试将为你量身打造专属的二次元魔法少女设定</p>
- </div>
- <button
- onClick={handleStartQuestionnaire}
- className="generate-button text-lg"
- style={{ marginBottom: '0rem' }}
- >
- 开始问卷
- </button>
- <div className="text-center" style={{ marginTop: '2rem' }}>
- <button
- onClick={() => router.push('/')}
- className="footer-link"
- >
- 返回首页
- </button>
- </div>
- </div>
- ) : (
- <>
- <div style={{ marginBottom: '1.5rem' }}>
- <div className="flex justify-between items-center" style={{ marginBottom: '0.5rem' }}>
- <span className="text-sm text-gray-600">
- 问题 {currentQuestionIndex + 1} / {questions.length}
- </span>
- <span className="text-sm text-gray-600">
- {Math.round(((currentQuestionIndex + 1) / questions.length) * 100)}%
- </span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="h-2 rounded-full transition-all duration-300"
- style={{
- width: `${((currentQuestionIndex + 1) / questions.length) * 100}%`,
- background: 'linear-gradient(to right, #ff9a9e, #fecfef)'
- }}
- />
- </div>
- </div>
- <div style={{ marginBottom: '1rem', minHeight: '80px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
- <h2
- className="text-xl font-medium leading-relaxed text-center text-pink-900"
- style={{
- opacity: isTransitioning ? 0 : 1,
- transition: 'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
- transform: isTransitioning ? 'translateX(-100px)' : 'translateX(0)',
- animation: !isTransitioning && currentQuestionIndex > 0 ? 'slideInFromRight 0.3s ease-out' : 'none'
- }}
- >
- {questions[currentQuestionIndex]}
- </h2>
- </div>
- <div className="input-group">
- <textarea
- value={currentAnswer}
- onChange={(e) => setCurrentAnswer(e.target.value)}
- placeholder="请输入您的答案(不超过30字)"
- className="input-field resize-none h-24"
- maxLength={30}
- />
- <div className="text-right text-sm text-gray-500" style={{ marginTop: '-2rem', marginRight: '0.5rem' }}>
- {currentAnswer.length}/30
- </div>
- </div>
- <div className="flex gap-2 justify-center" style={{ marginBottom: '1rem', marginTop: '2rem' }}>
- <button
- onClick={() => handleQuickOption('还没想好')}
- disabled={isTransitioning}
- className="generate-button h-10"
- style={{ marginBottom: 0, padding: 0 }}
- >
- 还没想好
- </button>
- <button
- onClick={() => handleQuickOption('不想回答')}
- disabled={isTransitioning}
- className="generate-button h-10"
- style={{ marginBottom: 0, padding: 0 }}
- >
- 不想回答
- </button>
- </div>
- <button
- onClick={handleNext}
- disabled={submitting || currentAnswer.trim().length === 0 || isTransitioning || isCooldown}
- className="generate-button"
- >
- {isCooldown
- ? `请等待 ${remainingTime} 秒`
- : submitting
- ? (
- <span className="flex items-center justify-center">
- <svg className="animate-spin h-4 w-4 text-white" style={{ marginLeft: '-0.25rem', marginRight: '0.5rem' }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- 提交中...
- </span>
- )
- : isLastQuestion
- ? '提交'
- : '下一题'}
- </button>
- {error && (
- <div className="error-message">
- {error}
- </div>
- )}
- <div className="text-center" style={{ marginTop: '1rem' }}>
- <button
- onClick={() => router.push('/')}
- className="footer-link"
- >
- 返回首页
- </button>
- </div>
- </>
- )}
- </div>
- {magicalGirlDetails && (
- <>
- <MagicalGirlCardACG
- magicalGirl={magicalGirlDetails}
- gradientStyle="linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)"
- onSaveImage={handleSaveImage}
- />
- <div className="card" style={{ marginTop: '1rem' }}>
- <div className="text-center">
- <button
- onClick={() => setShowDetails(!showDetails)}
- className="text-lg font-medium text-pink-900 hover:text-pink-700 transition-colors duration-200"
- style={{ background: 'none', border: 'none', cursor: 'pointer' }}
- >
- {showDetails ? '点击收起设定说明' : '点击展开设定说明'} {showDetails ? '▼' : '▶'}
- </button>
- {showDetails && (
- <div className="text-left" style={{ marginTop: '1rem' }}>
- <div className="mb-4">
- <h4 className="font-medium text-pink-800 mb-2">1. 魔法武器</h4>
- <p className="text-sm text-gray-700 leading-relaxed">
- 每个魔法少女都拥有独特的魔法武器,这是她们力量的源泉和象征。武器的形态和能力多种多样,完全反映了持有者的内心和特质。
- </p>
- </div>
- <div className="mb-4">
- <h4 className="font-medium text-pink-800 mb-2">2. 必杀技</h4>
- <p className="text-sm text-gray-700 leading-relaxed">
- 必杀技是魔法少女最强大的招式,通常需要咏唱咒语并伴随着华丽的动画演出。这是她们在关键时刻扭转战局、守护重要之物的最终王牌。
- </p>
- </div>
- <div className="mb-4">
- <h4 className="font-medium text-pink-800 mb-2">3. 觉醒/超进化</h4>
- <p className="text-sm text-gray-700 leading-relaxed">
- 当魔法少女的意志和情感达到顶峰时,她们可能会迎来力量的觉醒或超进化。这不仅会带来更华丽的服装和更强大的武器,更象征着角色的成长和蜕变。
- </p>
- </div>
- </div>
- )}
- </div>
- </div>
- <div className="card" style={{ marginTop: '1rem' }}>
- <div className="text-center">
- <h3 className="text-lg font-medium text-pink-900" style={{ marginBottom: '1rem' }}>保存人物设定</h3>
- <SaveJsonButton magicalGirlDetails={magicalGirlDetails} answers={answers} />
- </div>
- </div>
- </>
- )}
- <footer className="footer text-white">
- <p className="text-white">
- 问卷与系统设计 <a className="footer-link">@末伏之夜</a> <a className="footer-link">@Luxnk</a>
- </p>
- <p className="text-white">
- <a href="https://github.com/colasama" target="_blank" rel="noopener noreferrer" className="footer-link">@Colanns</a> 急速出品
- </p>
- <p className="text-white">
- 本项目 AI 能力由
- <a href="https://github.com/KouriChat/KouriChat" target="_blank" rel="noopener noreferrer" className="footer-link">KouriChat</a> &
- <a href="https://api.kourichat.com/" target="_blank" rel="noopener noreferrer" className="footer-link">Kouri API</a>
- 强力支持
- </p>
- <p className="text-white">
- <a href="https://github.com/colasama/MahoShojo-Generator" target="_blank" rel="noopener noreferrer" className="footer-link">colasama/MahoShojo-Generator</a>
- </p>
- </footer>
- </div>
- {showImageModal && savedImageUrl && (
- <div className="fixed inset-0 bg-black flex items-center justify-center z-50"
- style={{ backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingLeft: '2rem', paddingRight: '2rem' }}
- >
- <div className="bg-white rounded-lg max-w-lg w-full max-h-[80vh] overflow-auto relative">
- <div className="flex justify-between items-center m-0">
- <div></div>
- <button
- onClick={() => setShowImageModal(false)}
- className="text-gray-500 hover:text-gray-700 text-3xl leading-none"
- style={{ marginRight: '0.5rem' }}
- >
- ×
- </button>
- </div>
- <p className="text-center text-sm text-gray-600" style={{ marginTop: '0.5rem' }}>
- 💫 长按图片保存到相册
- </p>
- <div className="items-center flex flex-col" style={{ padding: '0.5rem' }}>
- <img
- src={savedImageUrl}
- alt="魔法少女详细档案"
- className="w-1/2 h-auto rounded-lg mx-auto"
- />
- </div>
- </div>
- </div>
- )}
- </div>
- </>
- );
- };
- export default ACGPage;
|