details.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. import React, { useState, useEffect } from 'react';
  2. import Head from 'next/head';
  3. import { useRouter } from 'next/router';
  4. import MagicalGirlCard from '../components/MagicalGirlCard';
  5. import { useCooldown } from '../lib/cooldown';
  6. import { quickCheck } from '@/lib/sensitive-word-filter';
  7. interface Questionnaire {
  8. questions: string[];
  9. }
  10. interface MagicalGirlDetails {
  11. codename: string;
  12. appearance: {
  13. outfit: string;
  14. accessories: string;
  15. colorScheme: string;
  16. overallLook: string;
  17. };
  18. magicConstruct: {
  19. name: string;
  20. form: string;
  21. basicAbilities: string[];
  22. description: string;
  23. };
  24. wonderlandRule: {
  25. name: string;
  26. description: string;
  27. tendency: string;
  28. activation: string;
  29. };
  30. blooming: {
  31. name: string;
  32. evolvedAbilities: string[];
  33. evolvedForm: string;
  34. evolvedOutfit: string;
  35. powerLevel: string;
  36. };
  37. analysis: {
  38. personalityAnalysis: string;
  39. abilityReasoning: string;
  40. coreTraits: string[];
  41. predictionBasis: string;
  42. };
  43. }
  44. const SaveJsonButton: React.FC<{ magicalGirlDetails: MagicalGirlDetails; answers: string[] }> = ({ magicalGirlDetails, answers }) => {
  45. const [isMobile, setIsMobile] = useState(false);
  46. const [showJsonText, setShowJsonText] = useState(false);
  47. useEffect(() => {
  48. const userAgent = navigator.userAgent.toLowerCase();
  49. const isMobileDevice = /mobile|android|iphone|ipad|ipod|blackberry|iemobile|opera mini/.test(userAgent);
  50. setIsMobile(isMobileDevice);
  51. }, []);
  52. const downloadJson = () => {
  53. // 将用户答案添加到保存的数据中
  54. const dataToSave = {
  55. ...magicalGirlDetails,
  56. userAnswers: answers
  57. };
  58. const jsonData = JSON.stringify(dataToSave, null, 2);
  59. const blob = new Blob([jsonData], { type: 'application/json' });
  60. const url = URL.createObjectURL(blob);
  61. const link = document.createElement('a');
  62. link.href = url;
  63. link.download = `魔法少女_${magicalGirlDetails.codename || 'data'}.json`;
  64. document.body.appendChild(link);
  65. link.click();
  66. document.body.removeChild(link);
  67. URL.revokeObjectURL(url);
  68. };
  69. const handleSave = () => {
  70. if (isMobile) {
  71. setShowJsonText(true);
  72. } else {
  73. downloadJson();
  74. }
  75. };
  76. if (showJsonText) {
  77. return (
  78. <div className="text-left">
  79. <div className="mb-4 text-center">
  80. <p className="text-sm text-gray-600 mb-2">请复制以下数据并保存</p>
  81. <button
  82. onClick={() => setShowJsonText(false)}
  83. className="text-blue-600 text-sm"
  84. >
  85. 返回
  86. </button>
  87. </div>
  88. <textarea
  89. value={JSON.stringify({ ...magicalGirlDetails, userAnswers: answers }, null, 2)}
  90. readOnly
  91. className="w-full h-64 p-3 border rounded-lg text-xs font-mono bg-gray-50 text-gray-900"
  92. onClick={(e) => (e.target as HTMLTextAreaElement).select()}
  93. />
  94. <p className="text-xs text-gray-500 mt-2 text-center">点击文本框可全选内容</p>
  95. </div>
  96. );
  97. }
  98. return (
  99. <button
  100. onClick={handleSave}
  101. className="generate-button"
  102. >
  103. {isMobile ? '查看原始数据' : '下载设定文件'}
  104. </button>
  105. );
  106. };
  107. const DetailsPage: React.FC = () => {
  108. const router = useRouter();
  109. const [questions, setQuestions] = useState<string[]>([]);
  110. const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
  111. const [answers, setAnswers] = useState<string[]>([]);
  112. const [currentAnswer, setCurrentAnswer] = useState('');
  113. const [loading, setLoading] = useState(true);
  114. const [submitting, setSubmitting] = useState(false);
  115. const [isTransitioning, setIsTransitioning] = useState(false);
  116. const [magicalGirlDetails, setMagicalGirlDetails] = useState<MagicalGirlDetails | null>(null);
  117. const [showImageModal, setShowImageModal] = useState(false);
  118. const [savedImageUrl, setSavedImageUrl] = useState<string | null>(null);
  119. const [showIntroduction, setShowIntroduction] = useState(true);
  120. const [error, setError] = useState<string | null>(null);
  121. const [showDetails, setShowDetails] = useState(false);
  122. const { isCooldown, startCooldown, remainingTime } = useCooldown('generateDetailsCooldown', 60000);
  123. useEffect(() => {
  124. // 加载问卷数据
  125. fetch('/questionnaire.json')
  126. .then(response => {
  127. if (!response.ok) {
  128. throw new Error('加载问卷文件失败');
  129. }
  130. return response.json();
  131. })
  132. .then((data: Questionnaire) => {
  133. setQuestions(data.questions);
  134. setAnswers(new Array(data.questions.length).fill(''));
  135. setLoading(false);
  136. })
  137. .catch(error => {
  138. console.error('加载问卷失败:', error);
  139. setError('📋 加载问卷失败,请刷新页面重试');
  140. setLoading(false);
  141. });
  142. }, []);
  143. const handleNext = () => {
  144. if (currentAnswer.trim().length === 0) {
  145. setError('⚠️ 请输入答案后再继续');
  146. return;
  147. }
  148. if (currentAnswer.length > 30) {
  149. setError('⚠️ 答案不能超过30字');
  150. return;
  151. }
  152. setError(null); // 清除错误信息
  153. proceedToNextQuestion(currentAnswer.trim());
  154. };
  155. const handleQuickOption = (option: string) => {
  156. proceedToNextQuestion(option);
  157. };
  158. const proceedToNextQuestion = (answer: string) => {
  159. // 保存当前答案
  160. const newAnswers = [...answers];
  161. newAnswers[currentQuestionIndex] = answer;
  162. setAnswers(newAnswers);
  163. if (currentQuestionIndex < questions.length - 1) {
  164. // 开始渐变动画
  165. setIsTransitioning(true);
  166. // 延迟切换题目,让淡出动画完成
  167. setTimeout(() => {
  168. setCurrentQuestionIndex(currentQuestionIndex + 1);
  169. setCurrentAnswer(newAnswers[currentQuestionIndex + 1] || '');
  170. // 短暂延迟后开始淡入动画
  171. setTimeout(() => {
  172. setIsTransitioning(false);
  173. }, 50);
  174. }, 250);
  175. } else {
  176. // 提交
  177. handleSubmit(newAnswers);
  178. }
  179. };
  180. const handleSubmit = async (finalAnswers: string[]) => {
  181. if (isCooldown) {
  182. setError(`请等待 ${remainingTime} 秒后再生成`);
  183. return;
  184. }
  185. setSubmitting(true);
  186. setError(null); // 清除之前的错误
  187. // 检查
  188. console.log('检查敏感词:', finalAnswers.join(''));
  189. const result = await quickCheck(finalAnswers.join(''));
  190. if (result.hasSensitiveWords) {
  191. router.push('/arrested');
  192. return;
  193. }
  194. try {
  195. console.log('提交答案:', finalAnswers);
  196. const response = await fetch('/api/generate-magical-girl-details', {
  197. method: 'POST',
  198. headers: {
  199. 'Content-Type': 'application/json',
  200. },
  201. body: JSON.stringify({ answers: finalAnswers })
  202. });
  203. if (!response.ok) {
  204. const errorData = await response.json();
  205. // 处理不同的 HTTP 状态码
  206. if (response.status === 429) {
  207. const retryAfter = errorData.retryAfter || 60;
  208. throw new Error(`请求过于频繁!请等待 ${retryAfter} 秒后再试。`);
  209. } else if (response.status >= 500) {
  210. throw new Error('服务器内部错误,请稍后重试');
  211. } else {
  212. throw new Error(errorData.message || errorData.error || '生成失败');
  213. }
  214. }
  215. const result: MagicalGirlDetails = await response.json();
  216. console.log('生成结果:', result);
  217. setMagicalGirlDetails(result);
  218. setError(null); // 成功时清除错误
  219. } catch (error) {
  220. console.error('提交失败:', error);
  221. // 处理不同类型的错误
  222. if (error instanceof Error) {
  223. const errorMessage = error.message;
  224. // 检查是否是 rate limit 错误
  225. if (errorMessage.includes('请求过于频繁')) {
  226. setError('🚫 请求太频繁了!每2分钟只能生成一次哦~请稍后再试吧!');
  227. } else if (errorMessage.includes('网络') || error instanceof TypeError) {
  228. setError('🌐 网络连接有问题!请检查网络后重试~');
  229. } else {
  230. setError(`✨ 魔法失效了!${errorMessage}`);
  231. }
  232. } else {
  233. setError('✨ 魔法失效了!生成详情时发生未知错误,请重试');
  234. }
  235. } finally {
  236. setSubmitting(false);
  237. startCooldown();
  238. }
  239. };
  240. const handleSaveImage = (imageUrl: string) => {
  241. setSavedImageUrl(imageUrl);
  242. setShowImageModal(true);
  243. };
  244. const handleStartQuestionnaire = () => {
  245. setShowIntroduction(false);
  246. };
  247. if (loading) {
  248. return (
  249. <div className="magic-background">
  250. <div className="container">
  251. <div className="card">
  252. <div className="text-center text-lg">加载中...</div>
  253. </div>
  254. </div>
  255. </div>
  256. );
  257. }
  258. if (questions.length === 0) {
  259. return (
  260. <div className="magic-background">
  261. <div className="container">
  262. <div className="card">
  263. <div className="error-message">加载问卷失败</div>
  264. </div>
  265. </div>
  266. </div>
  267. );
  268. }
  269. const isLastQuestion = currentQuestionIndex === questions.length - 1;
  270. return (
  271. <>
  272. <Head>
  273. <title>魔法少女调查问卷 ~ 奇妙妖精大调查</title>
  274. <meta name="description" content="回答问卷,生成您的专属魔法少女" />
  275. <meta name="viewport" content="width=device-width, initial-scale=1" />
  276. <link rel="icon" href="/favicon.ico" />
  277. </Head>
  278. <div className="magic-background">
  279. <div className="container">
  280. <div className="card">
  281. {/* Logo */}
  282. <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '1rem' }}>
  283. <img src="/questionnaire-logo.svg" width={250} height={160} alt="Questionnaire Logo" />
  284. </div>
  285. {showIntroduction ? (
  286. // 介绍部分
  287. <div className="text-center">
  288. <div className="mb-6 leading-relaxed text-gray-800"
  289. style={{ lineHeight: '1.5', marginTop: '3rem', marginBottom: '4rem' }}
  290. >
  291. 你在魔法少女道路上的潜力和表现将会如何?<br />
  292. <p style={{ fontSize: '0.8rem', marginTop: '1rem', color: '#999', fontStyle: 'italic' }}>本测试设定来源于小说《下班,然后变成魔法少女》</p>
  293. <p style={{ fontSize: '0.8rem', marginTop: '0.2rem', color: '#999', fontStyle: '' }}><del>以及广告位募集中</del></p>
  294. <p style={{ fontSize: '0.8rem', marginTop: '0.2rem', color: '#999', fontStyle: '' }}><del>如有意向请联系魔法国度研究院院长 @祖母绿:1********</del></p>
  295. </div>
  296. <button
  297. onClick={handleStartQuestionnaire}
  298. className="generate-button text-lg"
  299. style={{ marginBottom: '0rem' }}
  300. >
  301. 开始回答
  302. </button>
  303. {/* 返回首页链接 */}
  304. <div className="text-center" style={{ marginTop: '2rem' }}>
  305. <button
  306. onClick={() => router.push('/')}
  307. className="footer-link"
  308. >
  309. 返回首页
  310. </button>
  311. </div>
  312. </div>
  313. ) : (
  314. // 问卷部分
  315. <>
  316. {/* 进度指示器 */}
  317. <div style={{ marginBottom: '1.5rem' }}>
  318. <div className="flex justify-between items-center" style={{ marginBottom: '0.5rem' }}>
  319. <span className="text-sm text-gray-600">
  320. 问题 {currentQuestionIndex + 1} / {questions.length}
  321. </span>
  322. <span className="text-sm text-gray-600">
  323. {Math.round(((currentQuestionIndex + 1) / questions.length) * 100)}%
  324. </span>
  325. </div>
  326. <div className="w-full bg-gray-200 rounded-full h-2">
  327. <div
  328. className="h-2 rounded-full transition-all duration-300"
  329. style={{
  330. width: `${((currentQuestionIndex + 1) / questions.length) * 100}%`,
  331. background: 'linear-gradient(to right, #3b82f6, #1d4ed8)'
  332. }}
  333. />
  334. </div>
  335. </div>
  336. {/* 问题 */}
  337. <div style={{ marginBottom: '1rem', minHeight: '80px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  338. <h2
  339. className="text-xl font-medium leading-relaxed text-center text-blue-900"
  340. style={{
  341. opacity: isTransitioning ? 0 : 1,
  342. transition: 'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
  343. transform: isTransitioning ? 'translateX(-100px)' : 'translateX(0)',
  344. animation: !isTransitioning && currentQuestionIndex > 0 ? 'slideInFromRight 0.3s ease-out' : 'none'
  345. }}
  346. >
  347. {questions[currentQuestionIndex]}
  348. </h2>
  349. </div>
  350. {/* 输入框 */}
  351. <div className="input-group">
  352. <textarea
  353. value={currentAnswer}
  354. onChange={(e) => setCurrentAnswer(e.target.value)}
  355. placeholder="请输入您的答案(不超过30字)"
  356. className="input-field resize-none h-24"
  357. maxLength={30}
  358. />
  359. <div className="text-right text-sm text-gray-500" style={{ marginTop: '-2rem', marginRight: '0.5rem' }}>
  360. {currentAnswer.length}/30
  361. </div>
  362. </div>
  363. {/* 快捷选项 */}
  364. <div className="flex gap-2 justify-center" style={{ marginBottom: '1rem', marginTop: '2rem' }}>
  365. <button
  366. onClick={() => handleQuickOption('还没想好')}
  367. disabled={isTransitioning}
  368. className="generate-button h-10"
  369. style={{ marginBottom: 0, padding: 0 }}
  370. >
  371. 还没想好
  372. </button>
  373. <button
  374. onClick={() => handleQuickOption('不想回答')}
  375. disabled={isTransitioning}
  376. className="generate-button h-10"
  377. style={{ marginBottom: 0, padding: 0 }}
  378. >
  379. 不想回答
  380. </button>
  381. </div>
  382. {/* 下一题按钮 */}
  383. <button
  384. onClick={handleNext}
  385. disabled={submitting || currentAnswer.trim().length === 0 || isTransitioning || isCooldown}
  386. className="generate-button"
  387. >
  388. {isCooldown
  389. ? `请等待 ${remainingTime} 秒`
  390. : submitting
  391. ? (
  392. <span className="flex items-center justify-center">
  393. <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">
  394. <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
  395. <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>
  396. </svg>
  397. 提交中...
  398. </span>
  399. )
  400. : isLastQuestion
  401. ? '提交'
  402. : '下一题'}
  403. </button>
  404. {/* 错误信息显示 */}
  405. {error && (
  406. <div className="error-message">
  407. {error}
  408. </div>
  409. )}
  410. {/* 返回首页链接 */}
  411. <div className="text-center" style={{ marginTop: '1rem' }}>
  412. <button
  413. onClick={() => router.push('/')}
  414. className="footer-link"
  415. >
  416. 返回首页
  417. </button>
  418. </div>
  419. </>
  420. )}
  421. </div>
  422. {/* 显示魔法少女详细信息结果 */}
  423. {magicalGirlDetails && (
  424. <>
  425. <MagicalGirlCard
  426. magicalGirl={magicalGirlDetails}
  427. gradientStyle="linear-gradient(135deg, #9775fa 0%, #b197fc 100%)"
  428. onSaveImage={handleSaveImage}
  429. />
  430. {/* 关键解释抽屉 点击展开 点击关闭 */}
  431. <div className="card" style={{ marginTop: '1rem' }}>
  432. <div className="text-center">
  433. <button
  434. onClick={() => setShowDetails(!showDetails)}
  435. className="text-lg font-medium text-blue-900 hover:text-blue-700 transition-colors duration-200"
  436. style={{ background: 'none', border: 'none', cursor: 'pointer' }}
  437. >
  438. {showDetails ? '点击收起设定说明' : '点击展开设定说明'} {showDetails ? '▼' : '▶'}
  439. </button>
  440. {showDetails && (
  441. <div className="text-left" style={{ marginTop: '1rem' }}>
  442. <div className="mb-4">
  443. <h4 className="font-medium text-blue-800 mb-2">1. 魔力构装(简称魔装)</h4>
  444. <p className="text-sm text-gray-700 leading-relaxed">
  445. 魔法少女的本相魔力所孕育的能力具现,是魔法少女能力体系的基础。一般呈现为魔法少女在现实生活中接触过,在冥冥之中与其命运关联或映射的物体,并且与魔法少女特色能力相关。例如,泡泡机形态的魔装可以使魔法少女制造魔法泡泡,而这些泡泡可以拥有产生幻象、缓冲防护、束缚困敌等能力。这部分的内容需包含魔装的名字(通常为2字词),魔装的形态,魔装的基本能力。
  446. </p>
  447. </div>
  448. <div className="mb-4">
  449. <h4 className="font-medium text-blue-800 mb-2">2. 奇境规则</h4>
  450. <p className="text-sm text-gray-700 leading-relaxed">
  451. 魔法少女的本相灵魂所孕育的能力,是魔装能力的一体两面。奇境是魔装能力在规则层面上的升华,体现为与魔装相关的规则领域,而规则的倾向则会根据魔法少女的倾向而有不同的发展。例如,泡泡机形态的魔装升华而来的奇境规则可以是倾向于守护的&ldquo;戳破泡泡的东西将会立即无效化&rdquo;,也可以是倾向于进攻的&ldquo;沾到身上的泡泡被戳破会立即遭受伤害&rdquo;。
  452. </p>
  453. </div>
  454. <div className="mb-4">
  455. <h4 className="font-medium text-blue-800 mb-2">3. 繁开</h4>
  456. <p className="text-sm text-gray-700 leading-relaxed">
  457. 是魔法少女魔装能力的二段进化与解放,无论是作为魔法少女的魔力衣装还是魔装的武器外形都会发生改变。需包含繁开状态魔装名(需要包含原魔装名的每个字),繁开后的进化能力,繁开后的魔装形态,繁开后的魔法少女衣装样式(在通常变身外观上的升级与改变)。
  458. </p>
  459. </div>
  460. </div>
  461. )}
  462. </div>
  463. </div>
  464. {/* 保存原始数据按钮 */}
  465. <div className="card" style={{ marginTop: '1rem' }}>
  466. <div className="text-center">
  467. <h3 className="text-lg font-medium text-blue-900" style={{ marginBottom: '1rem' }}>保存人物设定</h3>
  468. <SaveJsonButton magicalGirlDetails={magicalGirlDetails} answers={answers} />
  469. </div>
  470. </div>
  471. </>
  472. )}
  473. <footer className="footer text-white">
  474. <p className="text-white">
  475. 问卷与系统设计 <a className="footer-link">@末伏之夜</a>
  476. </p>
  477. <p className="text-white">
  478. <a href="https://github.com/colasama" target="_blank" rel="noopener noreferrer" className="footer-link">@Colanns</a> 急速出品
  479. </p>
  480. <p className="text-white">
  481. 本项目 AI 能力由&nbsp;
  482. <a href="https://github.com/KouriChat/KouriChat" target="_blank" rel="noopener noreferrer" className="footer-link">KouriChat</a> &&nbsp;
  483. <a href="https://api.kourichat.com/" target="_blank" rel="noopener noreferrer" className="footer-link">Kouri API</a>
  484. &nbsp;强力支持
  485. </p>
  486. <p className="text-white">
  487. <a href="https://github.com/colasama/MahoShojo-Generator" target="_blank" rel="noopener noreferrer" className="footer-link">colasama/MahoShojo-Generator</a>
  488. </p>
  489. </footer>
  490. </div>
  491. {/* Image Modal */}
  492. {showImageModal && savedImageUrl && (
  493. <div className="fixed inset-0 bg-black flex items-center justify-center z-50"
  494. style={{ backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingLeft: '2rem', paddingRight: '2rem' }}
  495. >
  496. <div className="bg-white rounded-lg max-w-lg w-full max-h-[80vh] overflow-auto relative">
  497. <div className="flex justify-between items-center m-0">
  498. <div></div>
  499. <button
  500. onClick={() => setShowImageModal(false)}
  501. className="text-gray-500 hover:text-gray-700 text-3xl leading-none"
  502. style={{ marginRight: '0.5rem' }}
  503. >
  504. ×
  505. </button>
  506. </div>
  507. <p className="text-center text-sm text-gray-600" style={{ marginTop: '0.5rem' }}>
  508. 💫 长按图片保存到相册
  509. </p>
  510. <div className="items-center flex flex-col" style={{ padding: '0.5rem' }}>
  511. <img
  512. src={savedImageUrl}
  513. alt="魔法少女详细档案"
  514. className="w-1/2 h-auto rounded-lg mx-auto"
  515. />
  516. </div>
  517. </div>
  518. </div>
  519. )}
  520. </div>
  521. </>
  522. );
  523. };
  524. export default DetailsPage;