acg.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import React, { useState, useEffect } from 'react';
  2. import Head from 'next/head';
  3. import { useRouter } from 'next/router';
  4. import MagicalGirlCardACG from '../components/MagicalGirlCardACG';
  5. import { useCooldown } from '../lib/cooldown';
  6. import { quickCheck } from '@/lib/sensitive-word-filter';
  7. interface Questionnaire {
  8. questions: string[];
  9. }
  10. interface MagicalGirlACG {
  11. codename: string;
  12. appearance: {
  13. outfit: string;
  14. accessories: string;
  15. colorScheme: string;
  16. overallLook: string;
  17. };
  18. magicWeapon: {
  19. name: string;
  20. form: string;
  21. basicAbilities: string[];
  22. description: string;
  23. };
  24. specialMove: {
  25. name: string;
  26. chant: string;
  27. description: string;
  28. };
  29. awakening: {
  30. name: string;
  31. evolvedAbilities: string[];
  32. evolvedForm: string;
  33. evolvedOutfit: string;
  34. };
  35. analysis: {
  36. personalityAnalysis: string;
  37. abilityReasoning: string;
  38. coreTraits: string[];
  39. predictionBasis: string;
  40. };
  41. }
  42. const SaveJsonButton: React.FC<{ magicalGirlDetails: MagicalGirlACG; answers: string[] }> = ({ magicalGirlDetails, answers }) => {
  43. const [isMobile, setIsMobile] = useState(false);
  44. const [showJsonText, setShowJsonText] = useState(false);
  45. useEffect(() => {
  46. const userAgent = navigator.userAgent.toLowerCase();
  47. const isMobileDevice = /mobile|android|iphone|ipad|ipod|blackberry|iemobile|opera mini/.test(userAgent);
  48. setIsMobile(isMobileDevice);
  49. }, []);
  50. const downloadJson = () => {
  51. const dataToSave = {
  52. ...magicalGirlDetails,
  53. userAnswers: answers
  54. };
  55. const jsonData = JSON.stringify(dataToSave, null, 2);
  56. const blob = new Blob([jsonData], { type: 'application/json' });
  57. const url = URL.createObjectURL(blob);
  58. const link = document.createElement('a');
  59. link.href = url;
  60. link.download = `魔法少女_${magicalGirlDetails.codename || 'data'}.json`;
  61. document.body.appendChild(link);
  62. link.click();
  63. document.body.removeChild(link);
  64. URL.revokeObjectURL(url);
  65. };
  66. const handleSave = () => {
  67. if (isMobile) {
  68. setShowJsonText(true);
  69. } else {
  70. downloadJson();
  71. }
  72. };
  73. if (showJsonText) {
  74. return (
  75. <div className="text-left">
  76. <div className="mb-4 text-center">
  77. <p className="text-sm text-gray-600 mb-2">请复制以下数据并保存</p>
  78. <button
  79. onClick={() => setShowJsonText(false)}
  80. className="text-blue-600 text-sm"
  81. >
  82. 返回
  83. </button>
  84. </div>
  85. <textarea
  86. value={JSON.stringify({ ...magicalGirlDetails, userAnswers: answers }, null, 2)}
  87. readOnly
  88. className="w-full h-64 p-3 border rounded-lg text-xs font-mono bg-gray-50 text-gray-900"
  89. onClick={(e) => (e.target as HTMLTextAreaElement).select()}
  90. />
  91. <p className="text-xs text-gray-500 mt-2 text-center">点击文本框可全选内容</p>
  92. </div>
  93. );
  94. }
  95. return (
  96. <button
  97. onClick={handleSave}
  98. className="generate-button"
  99. >
  100. {isMobile ? '查看原始数据' : '下载设定文件'}
  101. </button>
  102. );
  103. };
  104. const ACGPage: React.FC = () => {
  105. const router = useRouter();
  106. const [questions, setQuestions] = useState<string[]>([]);
  107. const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
  108. const [answers, setAnswers] = useState<string[]>([]);
  109. const [currentAnswer, setCurrentAnswer] = useState('');
  110. const [loading, setLoading] = useState(true);
  111. const [submitting, setSubmitting] = useState(false);
  112. const [isTransitioning, setIsTransitioning] = useState(false);
  113. const [magicalGirlDetails, setMagicalGirlDetails] = useState<MagicalGirlACG | null>(null);
  114. const [showImageModal, setShowImageModal] = useState(false);
  115. const [savedImageUrl, setSavedImageUrl] = useState<string | null>(null);
  116. const [showIntroduction, setShowIntroduction] = useState(true);
  117. const [error, setError] = useState<string | null>(null);
  118. const [showDetails, setShowDetails] = useState(false);
  119. const { isCooldown, startCooldown, remainingTime } = useCooldown('generateDetailsCooldown', 60000);
  120. useEffect(() => {
  121. fetch('/acg_questionnaire.json')
  122. .then(response => {
  123. if (!response.ok) {
  124. throw new Error('加载问卷文件失败');
  125. }
  126. return response.json();
  127. })
  128. .then((data: Questionnaire) => {
  129. setQuestions(data.questions);
  130. setAnswers(new Array(data.questions.length).fill(''));
  131. setLoading(false);
  132. })
  133. .catch(error => {
  134. console.error('加载问卷失败:', error);
  135. setError('📋 加载问卷失败,请刷新页面重试');
  136. setLoading(false);
  137. });
  138. }, []);
  139. const handleNext = () => {
  140. if (currentAnswer.trim().length === 0) {
  141. setError('⚠️ 请输入答案后再继续');
  142. return;
  143. }
  144. if (currentAnswer.length > 30) {
  145. setError('⚠️ 答案不能超过30字');
  146. return;
  147. }
  148. setError(null);
  149. proceedToNextQuestion(currentAnswer.trim());
  150. };
  151. const handleQuickOption = (option: string) => {
  152. proceedToNextQuestion(option);
  153. };
  154. const proceedToNextQuestion = (answer: string) => {
  155. const newAnswers = [...answers];
  156. newAnswers[currentQuestionIndex] = answer;
  157. setAnswers(newAnswers);
  158. if (currentQuestionIndex < questions.length - 1) {
  159. setIsTransitioning(true);
  160. setTimeout(() => {
  161. setCurrentQuestionIndex(currentQuestionIndex + 1);
  162. setCurrentAnswer(newAnswers[currentQuestionIndex + 1] || '');
  163. setTimeout(() => {
  164. setIsTransitioning(false);
  165. }, 50);
  166. }, 250);
  167. } else {
  168. handleSubmit(newAnswers);
  169. }
  170. };
  171. const handleSubmit = async (finalAnswers: string[]) => {
  172. if (isCooldown) {
  173. setError(`请等待 ${remainingTime} 秒后再生成`);
  174. return;
  175. }
  176. setSubmitting(true);
  177. setError(null);
  178. const result = await quickCheck(finalAnswers.join(''));
  179. if (result.hasSensitiveWords) {
  180. router.push('/arrested');
  181. return;
  182. }
  183. try {
  184. const response = await fetch('/api/generate-magical-girl-acg', {
  185. method: 'POST',
  186. headers: {
  187. 'Content-Type': 'application/json',
  188. },
  189. body: JSON.stringify({ answers: finalAnswers })
  190. });
  191. if (!response.ok) {
  192. const errorData = await response.json();
  193. if (response.status === 429) {
  194. const retryAfter = errorData.retryAfter || 60;
  195. throw new Error(`请求过于频繁!请等待 ${retryAfter} 秒后再试。`);
  196. } else if (response.status >= 500) {
  197. throw new Error('服务器内部错误,请稍后重试');
  198. } else {
  199. throw new Error(errorData.message || errorData.error || '生成失败');
  200. }
  201. }
  202. const result: MagicalGirlACG = await response.json();
  203. setMagicalGirlDetails(result);
  204. setError(null);
  205. } catch (error) {
  206. if (error instanceof Error) {
  207. const errorMessage = error.message;
  208. if (errorMessage.includes('请求过于频繁')) {
  209. setError('🚫 请求太频繁了!每2分钟只能生成一次哦~请稍后再试吧!');
  210. } else if (errorMessage.includes('网络') || error instanceof TypeError) {
  211. setError('🌐 网络连接有问题!请检查网络后重试~');
  212. } else {
  213. setError(`✨ 魔法失效了!${errorMessage}`);
  214. }
  215. } else {
  216. setError('✨ 魔法失效了!生成详情时发生未知错误,请重试');
  217. }
  218. } finally {
  219. setSubmitting(false);
  220. startCooldown();
  221. }
  222. };
  223. const handleSaveImage = (imageUrl: string) => {
  224. setSavedImageUrl(imageUrl);
  225. setShowImageModal(true);
  226. };
  227. const handleStartQuestionnaire = () => {
  228. setShowIntroduction(false);
  229. };
  230. if (loading) {
  231. return (
  232. <div className="magic-background">
  233. <div className="container">
  234. <div className="card">
  235. <div className="text-center text-lg">加载中...</div>
  236. </div>
  237. </div>
  238. </div>
  239. );
  240. }
  241. if (questions.length === 0) {
  242. return (
  243. <div className="magic-background">
  244. <div className="container">
  245. <div className="card">
  246. <div className="error-message">加载问卷失败</div>
  247. </div>
  248. </div>
  249. </div>
  250. );
  251. }
  252. const isLastQuestion = currentQuestionIndex === questions.length - 1;
  253. return (
  254. <>
  255. <Head>
  256. <title>魔法少女调查问卷 ~ 二次元篇</title>
  257. <meta name="description" content="回答问卷,生成您的专属二次元魔法少女" />
  258. <meta name="viewport" content="width=device-width, initial-scale=1" />
  259. <link rel="icon" href="/favicon.ico" />
  260. </Head>
  261. <div className="magic-background">
  262. <div className="container">
  263. <div className="card">
  264. <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '1rem' }}>
  265. <img src="/questionnaire-logo.svg" width={250} height={160} alt="Questionnaire Logo" />
  266. </div>
  267. {showIntroduction ? (
  268. <div className="text-center">
  269. <div className="mb-6 leading-relaxed text-gray-800"
  270. style={{ lineHeight: '1.5', marginTop: '3rem', marginBottom: '4rem' }}
  271. >
  272. 回答这些问题,唤醒你心中沉睡的魔法少女之魂!<br />
  273. <p style={{ fontSize: '0.8rem', marginTop: '1rem', color: '#999', fontStyle: 'italic' }}>本测试将为你量身打造专属的二次元魔法少女设定</p>
  274. </div>
  275. <button
  276. onClick={handleStartQuestionnaire}
  277. className="generate-button text-lg"
  278. style={{ marginBottom: '0rem' }}
  279. >
  280. 开始问卷
  281. </button>
  282. <div className="text-center" style={{ marginTop: '2rem' }}>
  283. <button
  284. onClick={() => router.push('/')}
  285. className="footer-link"
  286. >
  287. 返回首页
  288. </button>
  289. </div>
  290. </div>
  291. ) : (
  292. <>
  293. <div style={{ marginBottom: '1.5rem' }}>
  294. <div className="flex justify-between items-center" style={{ marginBottom: '0.5rem' }}>
  295. <span className="text-sm text-gray-600">
  296. 问题 {currentQuestionIndex + 1} / {questions.length}
  297. </span>
  298. <span className="text-sm text-gray-600">
  299. {Math.round(((currentQuestionIndex + 1) / questions.length) * 100)}%
  300. </span>
  301. </div>
  302. <div className="w-full bg-gray-200 rounded-full h-2">
  303. <div
  304. className="h-2 rounded-full transition-all duration-300"
  305. style={{
  306. width: `${((currentQuestionIndex + 1) / questions.length) * 100}%`,
  307. background: 'linear-gradient(to right, #ff9a9e, #fecfef)'
  308. }}
  309. />
  310. </div>
  311. </div>
  312. <div style={{ marginBottom: '1rem', minHeight: '80px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  313. <h2
  314. className="text-xl font-medium leading-relaxed text-center text-pink-900"
  315. style={{
  316. opacity: isTransitioning ? 0 : 1,
  317. transition: 'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
  318. transform: isTransitioning ? 'translateX(-100px)' : 'translateX(0)',
  319. animation: !isTransitioning && currentQuestionIndex > 0 ? 'slideInFromRight 0.3s ease-out' : 'none'
  320. }}
  321. >
  322. {questions[currentQuestionIndex]}
  323. </h2>
  324. </div>
  325. <div className="input-group">
  326. <textarea
  327. value={currentAnswer}
  328. onChange={(e) => setCurrentAnswer(e.target.value)}
  329. placeholder="请输入您的答案(不超过30字)"
  330. className="input-field resize-none h-24"
  331. maxLength={30}
  332. />
  333. <div className="text-right text-sm text-gray-500" style={{ marginTop: '-2rem', marginRight: '0.5rem' }}>
  334. {currentAnswer.length}/30
  335. </div>
  336. </div>
  337. <div className="flex gap-2 justify-center" style={{ marginBottom: '1rem', marginTop: '2rem' }}>
  338. <button
  339. onClick={() => handleQuickOption('还没想好')}
  340. disabled={isTransitioning}
  341. className="generate-button h-10"
  342. style={{ marginBottom: 0, padding: 0 }}
  343. >
  344. 还没想好
  345. </button>
  346. <button
  347. onClick={() => handleQuickOption('不想回答')}
  348. disabled={isTransitioning}
  349. className="generate-button h-10"
  350. style={{ marginBottom: 0, padding: 0 }}
  351. >
  352. 不想回答
  353. </button>
  354. </div>
  355. <button
  356. onClick={handleNext}
  357. disabled={submitting || currentAnswer.trim().length === 0 || isTransitioning || isCooldown}
  358. className="generate-button"
  359. >
  360. {isCooldown
  361. ? `请等待 ${remainingTime} 秒`
  362. : submitting
  363. ? (
  364. <span className="flex items-center justify-center">
  365. <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">
  366. <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
  367. <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>
  368. </svg>
  369. 提交中...
  370. </span>
  371. )
  372. : isLastQuestion
  373. ? '提交'
  374. : '下一题'}
  375. </button>
  376. {error && (
  377. <div className="error-message">
  378. {error}
  379. </div>
  380. )}
  381. <div className="text-center" style={{ marginTop: '1rem' }}>
  382. <button
  383. onClick={() => router.push('/')}
  384. className="footer-link"
  385. >
  386. 返回首页
  387. </button>
  388. </div>
  389. </>
  390. )}
  391. </div>
  392. {magicalGirlDetails && (
  393. <>
  394. <MagicalGirlCardACG
  395. magicalGirl={magicalGirlDetails}
  396. gradientStyle="linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)"
  397. onSaveImage={handleSaveImage}
  398. />
  399. <div className="card" style={{ marginTop: '1rem' }}>
  400. <div className="text-center">
  401. <button
  402. onClick={() => setShowDetails(!showDetails)}
  403. className="text-lg font-medium text-pink-900 hover:text-pink-700 transition-colors duration-200"
  404. style={{ background: 'none', border: 'none', cursor: 'pointer' }}
  405. >
  406. {showDetails ? '点击收起设定说明' : '点击展开设定说明'} {showDetails ? '▼' : '▶'}
  407. </button>
  408. {showDetails && (
  409. <div className="text-left" style={{ marginTop: '1rem' }}>
  410. <div className="mb-4">
  411. <h4 className="font-medium text-pink-800 mb-2">1. 魔法武器</h4>
  412. <p className="text-sm text-gray-700 leading-relaxed">
  413. 每个魔法少女都拥有独特的魔法武器,这是她们力量的源泉和象征。武器的形态和能力多种多样,完全反映了持有者的内心和特质。
  414. </p>
  415. </div>
  416. <div className="mb-4">
  417. <h4 className="font-medium text-pink-800 mb-2">2. 必杀技</h4>
  418. <p className="text-sm text-gray-700 leading-relaxed">
  419. 必杀技是魔法少女最强大的招式,通常需要咏唱咒语并伴随着华丽的动画演出。这是她们在关键时刻扭转战局、守护重要之物的最终王牌。
  420. </p>
  421. </div>
  422. <div className="mb-4">
  423. <h4 className="font-medium text-pink-800 mb-2">3. 觉醒/超进化</h4>
  424. <p className="text-sm text-gray-700 leading-relaxed">
  425. 当魔法少女的意志和情感达到顶峰时,她们可能会迎来力量的觉醒或超进化。这不仅会带来更华丽的服装和更强大的武器,更象征着角色的成长和蜕变。
  426. </p>
  427. </div>
  428. </div>
  429. )}
  430. </div>
  431. </div>
  432. <div className="card" style={{ marginTop: '1rem' }}>
  433. <div className="text-center">
  434. <h3 className="text-lg font-medium text-pink-900" style={{ marginBottom: '1rem' }}>保存人物设定</h3>
  435. <SaveJsonButton magicalGirlDetails={magicalGirlDetails} answers={answers} />
  436. </div>
  437. </div>
  438. </>
  439. )}
  440. <footer className="footer text-white">
  441. <p className="text-white">
  442. 问卷与系统设计 <a className="footer-link">@末伏之夜</a> <a className="footer-link">@Luxnk</a>
  443. </p>
  444. <p className="text-white">
  445. <a href="https://github.com/colasama" target="_blank" rel="noopener noreferrer" className="footer-link">@Colanns</a> 急速出品
  446. </p>
  447. <p className="text-white">
  448. 本项目 AI 能力由&nbsp;
  449. <a href="https://github.com/KouriChat/KouriChat" target="_blank" rel="noopener noreferrer" className="footer-link">KouriChat</a> &&nbsp;
  450. <a href="https://api.kourichat.com/" target="_blank" rel="noopener noreferrer" className="footer-link">Kouri API</a>
  451. &nbsp;强力支持
  452. </p>
  453. <p className="text-white">
  454. <a href="https://github.com/colasama/MahoShojo-Generator" target="_blank" rel="noopener noreferrer" className="footer-link">colasama/MahoShojo-Generator</a>
  455. </p>
  456. </footer>
  457. </div>
  458. {showImageModal && savedImageUrl && (
  459. <div className="fixed inset-0 bg-black flex items-center justify-center z-50"
  460. style={{ backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingLeft: '2rem', paddingRight: '2rem' }}
  461. >
  462. <div className="bg-white rounded-lg max-w-lg w-full max-h-[80vh] overflow-auto relative">
  463. <div className="flex justify-between items-center m-0">
  464. <div></div>
  465. <button
  466. onClick={() => setShowImageModal(false)}
  467. className="text-gray-500 hover:text-gray-700 text-3xl leading-none"
  468. style={{ marginRight: '0.5rem' }}
  469. >
  470. ×
  471. </button>
  472. </div>
  473. <p className="text-center text-sm text-gray-600" style={{ marginTop: '0.5rem' }}>
  474. 💫 长按图片保存到相册
  475. </p>
  476. <div className="items-center flex flex-col" style={{ padding: '0.5rem' }}>
  477. <img
  478. src={savedImageUrl}
  479. alt="魔法少女详细档案"
  480. className="w-1/2 h-auto rounded-lg mx-auto"
  481. />
  482. </div>
  483. </div>
  484. </div>
  485. )}
  486. </div>
  487. </>
  488. );
  489. };
  490. export default ACGPage;