Luxnk пре 5 месеци
комит
c98480b15d
53 измењених фајлова са 18857 додато и 0 уклоњено
  1. 6 0
      .eslintrc.json
  2. 41 0
      .gitignore
  3. 81 0
      CLAUDE.md
  4. 140 0
      README.md
  5. 1723 0
      bun.lock
  6. 21 0
      components.json
  7. 188 0
      components/MagicalGirlCard.tsx
  8. 184 0
      components/MagicalGirlCardACG.tsx
  9. 20 0
      config/ai-providers.example.json
  10. 41 0
      env.example
  11. 16 0
      eslint.config.mjs
  12. 116 0
      lib/ai.ts
  13. 97 0
      lib/config.ts
  14. 61 0
      lib/cooldown.ts
  15. 10 0
      lib/main-color.ts
  16. 4 0
      lib/random-choose-hana-name.ts
  17. 168 0
      lib/rate-limiter.ts
  18. 287 0
      lib/sensitive-word-filter.ts
  19. 18 0
      next.config.ts
  20. 12014 0
      package-lock.json
  21. 41 0
      package.json
  22. 31 0
      pages/_app.tsx
  23. 536 0
      pages/acg.tsx
  24. 150 0
      pages/api/generate-magical-girl-acg.ts
  25. 141 0
      pages/api/generate-magical-girl-details.ts
  26. 94 0
      pages/api/generate-magical-girl.ts
  27. 99 0
      pages/arrested.tsx
  28. 571 0
      pages/details.tsx
  29. 428 0
      pages/index.tsx
  30. 5 0
      postcss.config.mjs
  31. 2 0
      public/_headers
  32. 19 0
      public/acg_questionnaire.json
  33. 23 0
      public/arrest-frame.svg
  34. 24 0
      public/favicon.svg
  35. 1 0
      public/file.svg
  36. 1 0
      public/globe.svg
  37. 137 0
      public/logo-white-qrcode.svg
  38. 91 0
      public/logo-white.svg
  39. 85 0
      public/logo.svg
  40. 50 0
      public/mahou-title.svg
  41. 1 0
      public/next.svg
  42. 81 0
      public/questionnaire-logo.svg
  43. 144 0
      public/questionnaire-title.svg
  44. 20 0
      public/questionnaire.json
  45. 0 0
      public/tamamani.json
  46. 1 0
      public/vercel.svg
  47. 1 0
      public/window.svg
  48. 293 0
      styles/blue-theme.css
  49. 305 0
      styles/globals.css
  50. 185 0
      tests/getWeightedRandomFromSeed.test.js
  51. 28 0
      tsconfig.json
  52. 20 0
      types/html2canvas.d.ts
  53. 13 0
      wrangler.toml

+ 6 - 0
.eslintrc.json

@@ -0,0 +1,6 @@
+{
+  "extends": "next/core-web-vitals",
+  "rules": {
+    "@typescript-eslint/no-duplicate-enum-values": "off"
+  }
+}

+ 41 - 0
.gitignore

@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts

+ 81 - 0
CLAUDE.md

@@ -0,0 +1,81 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+IFMahouShoujo (魔法少女生成器) is a Magical Girl Generator - an AI-powered web application that creates personalized magical girl characters. Built with Next.js 15, React 19, Bun and TypeScript, it features a mobile-friendly design with a whimsical, magical theme.
+
+## Commands
+
+### Development
+```bash
+bun run dev         # Start development server with Turbopack
+bun run build       # Build for production
+bun run start       # Start production server
+bun run lint        # Run ESLint
+```
+
+### Environment Setup
+Create a `.env.local` file based on `env.example` with:
+- `NEXT_PUBLIC_AI_API_KEY`: OpenAI API key
+- `NEXT_PUBLIC_AI_BASE_URL`: API endpoint (optional, defaults to OpenAI)
+- `NEXT_PUBLIC_AI_MODEL`: AI model (optional, defaults to gpt-3.5-turbo)
+
+## Architecture
+
+### Hybrid Routing Structure
+The project uses both `pages/` (traditional) and `app/` (App Router) directories:
+- Main application logic is in `pages/index.tsx`
+- App Router is partially implemented in `app/` for future migration
+
+### Core Components
+- **pages/index.tsx**: Main magical girl generator interface
+- **lib/ai.ts**: AI integration using Vercel AI SDK with OpenAI
+- **lib/config.ts**: Environment configuration management
+- **lib/utils.ts**: Utility functions including deterministic level generation
+
+### AI Integration Pattern
+Uses Vercel AI SDK with Zod schemas for type-safe responses:
+```typescript
+// Schema defined with zod
+const magicalGirlSchema = z.object({
+  magicalName: z.string(),
+  attributes: z.object({...}),
+  transformationSpell: z.string()
+})
+
+// AI generation with structured output
+const { object } = await generateObject({
+  model: openai(config.model),
+  schema: magicalGirlSchema,
+  prompt: generatePrompt(name)
+})
+```
+
+### Level System
+Character levels are deterministically generated based on name hash:
+1. 种芽 (Seed) - Level 1
+2. 叶 (Leaf) - Level 2  
+3. 蕾 (Bud) - Level 3
+4. 花 (Flower) - Level 4
+5. 满开 (Full Bloom) - Level 5
+6. 宝石权杖 (Gem Scepter) - Level 6
+
+### Styling Approach
+- Tailwind CSS 4 with custom animations
+- shadcn/ui components (configured in components.json)
+- Mobile-first responsive design
+- Chinese language UI
+
+## Key Dependencies
+- **AI**: `@ai-sdk/openai`, `ai`, `zod`
+- **UI**: `tailwindcss`, `lucide-react`, `class-variance-authority`
+- **Image Export**: `html2canvas`
+- **Framework**: `next`, `react`, `typescript`
+
+## Development Notes
+- No testing framework currently configured
+- API keys are client-exposed (NEXT_PUBLIC_*)
+- Project is bilingual with Chinese comments and UI text
+- Uses Turbopack for faster development builds

+ 140 - 0
README.md

@@ -0,0 +1,140 @@
+<!-- markdownlint-disable MD033 MD041 -->
+<p align="center">
+  <img src="./public/logo.svg" width="300" height="200" alt="MahoGen">
+</p>
+
+<div align="center">
+  <!-- prettier-ignore-start -->
+  <!-- markdownlint-disable-next-line MD036 -->
+  <div>✨ 基于 AI 结构化生成的生成器 ✨</div>
+  <a href="https://mahoshojo.colanns.me">试玩地址</a>
+</div>
+
+## ✨ 介绍
+基于 AI 结构化生成的个性化魔法少女角色生成器,使用 Next.js 15 + React 19 + TypeScript + Vercel AI SDK 构建。
+
+支持多个 AI 提供商,推荐使用 `gemini-2.5-flash` 模型,输入你的名字即可生成专属的魔法少女角色!
+
+~~超级 Vibe 所以结构垃圾代码问题也很大仅供参考娱乐测试使用!~~
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Node.js 18+ 或 Bun 
+- 支持的 AI 提供商 API Key(Gemini 等)
+
+### 安装依赖
+
+```bash
+# 推荐使用 Bun
+bun install
+
+# 或使用 npm
+npm install
+```
+
+### 环境配置
+
+复制 `env.example` 为 `.env.local` 并配置你的 AI 提供商:
+
+```bash
+cp env.example .env.local
+```
+
+编辑 `.env.local`,配置 AI 提供商(支持多提供商自动故障转移):
+
+```shell
+AI_PROVIDERS_CONFIG='[
+  {{
+    "name": "gemini_provider", 
+    "apiKey": "your_gemini_api_key_here",
+    "baseUrl": "https://xxx.com/v1",
+    "model": "gemini-2.5-flash"
+  },
+  {
+    "name": "gemini_provider", 
+    "apiKey": "your_gemini_api_key_here",
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
+    "model": "gemini-2.5-flash"
+  }
+]'
+```
+
+### 运行开发服务器
+
+```bash
+# 使用 Bun(支持 Turbopack)
+bun run dev
+
+# 或使用 npm
+npm run dev
+```
+
+在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
+
+### 构建生产版本
+
+```bash
+bun run build
+bun run start
+# 或
+npm run build  
+npm run start
+```
+
+## 📋 开发进度
+
+- [x] AI 生成系统接入
+- [x] 多 AI 提供商支持
+- [x] 角色生成 Prompt Engineering
+- [x] 自适应渐变配色
+- [x] 图片保存功能优化
+- [x] 图片预加载性能优化
+- [ ] 立绘 AIGC 生成功能
+- [ ] 角色卡片模板扩展
+- [ ] 将系统通用化,模块化
+
+## 🧡 致谢
+<div align="center">
+  <p>本项目在线版本的大模型能力由</p>
+  <p><b><a href="https://github.com/KouriChat/KouriChat"> 
+    <img width="180" src="https://static.kourichat.com/pic/KouriChat.webp"/></br>
+    基于 LLM 的情感陪伴程序</br>
+    <span style="font-size: 20px">KouriChat</span>
+  </a></b></p>
+  <p>强力支持</p>
+  <p><b>GitHub</b> | <a href="https://github.com/KouriChat/KouriChat">https://github.com/KouriChat/KouriChat</a></p>
+  <p><b>项目官网</b> | <a href="https://kourichat.com/">https://kourichat.com/</a></p>
+</div>
+
+## 📁 项目结构
+
+```
+MahoShojo-Generator/
+├── pages/                    # Next.js 页面路由
+│   ├── _app.tsx             # 应用根组件
+│   ├── index.tsx            # 主页面 - 魔法少女生成器
+│   └── api/                 # API 路由
+│       └── generate-magical-girl.ts  # 角色生成 API
+├── lib/                     # 工具库
+│   ├── ai.ts               # AI 集成和类型定义
+│   └── config.ts           # 环境配置管理
+├── styles/                  # 样式文件
+│   └── globals.css         # 全局样式和动画
+├── public/                  # 静态资源
+│   ├── logo.svg            # 主 Logo
+│   ├── logo-white.svg      # 白色 Logo(用于保存图片)
+│   ├── mahou-title.svg     # 标题图标
+│   └── ...                 # 其他图标和资源
+├── types/                   # TypeScript 类型声明
+├── config/                  # 配置文件
+├── tests/                   # 测试文件
+├── env.example             # 环境变量示例
+└── ...                     # 配置文件
+```
+
+---
+
+<div style="text-align: center">✨ 为结构化生成献上祝福 ✨</div>
+

Разлика између датотеке није приказан због своје велике величине
+ 1723 - 0
bun.lock


+ 21 - 0
components.json

@@ -0,0 +1,21 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "styles/globals.css",
+    "baseColor": "slate",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "iconLibrary": "lucide"
+}

+ 188 - 0
components/MagicalGirlCard.tsx

@@ -0,0 +1,188 @@
+import React, { useRef } from 'react';
+import { snapdom } from '@zumer/snapdom';
+
+interface MagicalGirlCardProps {
+  magicalGirl: {
+    codename: string;
+    appearance: {
+      outfit: string;
+      accessories: string;
+      colorScheme: string;
+      overallLook: string;
+    };
+    magicConstruct: {
+      name: string;
+      form: string;
+      basicAbilities: string[];
+      description: string;
+    };
+    wonderlandRule: {
+      name: string;
+      description: string;
+      tendency: string;
+      activation: string;
+    };
+    blooming: {
+      name: string;
+      evolvedAbilities: string[];
+      evolvedForm: string;
+      evolvedOutfit: string;
+      powerLevel: string;
+    };
+    analysis: {
+      personalityAnalysis: string;
+      abilityReasoning: string;
+      coreTraits: string[];
+      predictionBasis: string;
+    };
+  };
+  gradientStyle: string;
+  onSaveImage?: (imageUrl: string) => void;
+}
+
+const MagicalGirlCard: React.FC<MagicalGirlCardProps> = ({
+  magicalGirl,
+  gradientStyle,
+  onSaveImage
+}) => {
+  const resultRef = useRef<HTMLDivElement>(null);
+
+  const handleSaveImage = async () => {
+    if (!resultRef.current) return;
+
+    try {
+      const saveButton = resultRef.current.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'none';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'flex';
+
+      const result = await snapdom(resultRef.current, {
+        scale: 1,
+      });
+
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+
+      const imgElement = await result.toPng();
+      const imageUrl = imgElement.src;
+
+      if (onSaveImage) {
+        onSaveImage(imageUrl);
+      }
+    } catch {
+      alert('生成图片失败,请重试');
+      const saveButton = resultRef.current?.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current?.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+    }
+  };
+
+  return (
+    <div
+      ref={resultRef}
+      className="result-card"
+      style={{ background: gradientStyle }}
+    >
+      <div className="result-content">
+        <div className="flex justify-center items-center" style={{ marginBottom: '1rem', background: 'transparent' }}>
+          <img src="/questionnaire-title.svg" width={300} height={70} alt="Logo" style={{ display: 'block', background: 'transparent' }} />
+        </div>
+
+        {/* 基本信息 */}
+        <div className="result-item">
+          <div className="result-label">💝 魔法少女代号</div>
+          <div className="result-value">{magicalGirl.codename}</div>
+        </div>
+
+        {/* 外观描述 */}
+        <div className="result-item">
+          <div className="result-label">👗 魔法少女外观</div>
+          <div className="result-value">
+            <div><strong>服装:</strong>{magicalGirl.appearance.outfit}</div>
+            <div><strong>饰品:</strong>{magicalGirl.appearance.accessories}</div>
+            <div><strong>配色:</strong>{magicalGirl.appearance.colorScheme}</div>
+            <div><strong>整体风格:</strong>{magicalGirl.appearance.overallLook}</div>
+          </div>
+        </div>
+
+        {/* 魔力构装 */}
+        <div className="result-item">
+          <div className="result-label">⚔️ 魔力构装</div>
+          <div className="result-value">
+            <div><strong>名称:</strong>{magicalGirl.magicConstruct.name}</div>
+            <div><strong>形态:</strong>{magicalGirl.magicConstruct.form}</div>
+            <div><strong>基本能力:</strong></div>
+            <ul style={{ marginLeft: '1rem', marginTop: '0.5rem' }}>
+              {magicalGirl.magicConstruct.basicAbilities.map((ability: string, index: number) => (
+                <li key={index}>• {ability}</li>
+              ))}
+            </ul>
+            <div style={{ marginTop: '0.5rem' }}><strong>详细描述:</strong>{magicalGirl.magicConstruct.description}</div>
+          </div>
+        </div>
+
+        {/* 奇境规则 */}
+        <div className="result-item">
+          <div className="result-label">🌟 奇境规则</div>
+          <div className="result-value">
+            <div><strong>规则名称:</strong>{magicalGirl.wonderlandRule.name}</div>
+            <div><strong>规则描述:</strong>{magicalGirl.wonderlandRule.description}</div>
+            <div><strong>规则倾向:</strong>{magicalGirl.wonderlandRule.tendency}</div>
+            <div><strong>激活条件:</strong>{magicalGirl.wonderlandRule.activation}</div>
+          </div>
+        </div>
+
+        {/* 繁开状态 */}
+        <div className="result-item">
+          <div className="result-label">🌸 繁开状态</div>
+          <div className="result-value">
+            <div><strong>繁开魔装名:</strong>{magicalGirl.blooming.name}</div>
+            <div><strong>进化能力:</strong></div>
+            <ul style={{ marginLeft: '1rem', marginTop: '0.5rem' }}>
+              {magicalGirl.blooming.evolvedAbilities.map((ability: string, index: number) => (
+                <li key={index}>• {ability}</li>
+              ))}
+            </ul>
+            <div><strong>进化形态:</strong>{magicalGirl.blooming.evolvedForm}</div>
+            <div><strong>进化衣装:</strong>{magicalGirl.blooming.evolvedOutfit}</div>
+            <div><strong>力量等级:</strong>{magicalGirl.blooming.powerLevel}</div>
+          </div>
+        </div>
+
+        {/* 性格分析 */}
+        <div className="result-item">
+          <div className="result-label">🔮 性格分析</div>
+          <div className="result-value">
+            <div><strong>性格分析:</strong>{magicalGirl.analysis.personalityAnalysis}</div>
+            <div><strong>能力推理:</strong>{magicalGirl.analysis.abilityReasoning}</div>
+            <div><strong>核心特征:</strong>{magicalGirl.analysis.coreTraits.join('、')}</div>
+            <div><strong>预测依据:</strong>{magicalGirl.analysis.predictionBasis}</div>
+          </div>
+        </div>
+
+        <button onClick={handleSaveImage} className="save-button">
+          📱 保存为图片
+        </button>
+
+        <div className="logo-placeholder" style={{ display: 'none', justifyContent: 'center', marginTop: '1rem' }}>
+          <img
+            src="/logo-white-qrcode.svg"
+            width={320}
+            height={80}
+            alt="Logo"
+            style={{
+              display: 'block',
+              maxWidth: '100%',
+              height: 'auto'
+            }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default MagicalGirlCard;

+ 184 - 0
components/MagicalGirlCardACG.tsx

@@ -0,0 +1,184 @@
+import React, { useRef } from 'react';
+import { snapdom } from '@zumer/snapdom';
+
+interface MagicalGirlCardACGProps {
+  magicalGirl: {
+    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;
+    };
+  };
+  gradientStyle: string;
+  onSaveImage?: (imageUrl: string) => void;
+}
+
+const MagicalGirlCardACG: React.FC<MagicalGirlCardACGProps> = ({
+  magicalGirl,
+  gradientStyle,
+  onSaveImage
+}) => {
+  const resultRef = useRef<HTMLDivElement>(null);
+
+  const handleSaveImage = async () => {
+    if (!resultRef.current) return;
+
+    try {
+      const saveButton = resultRef.current.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'none';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'flex';
+
+      const result = await snapdom(resultRef.current, {
+        scale: 1,
+      });
+
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+
+      const imgElement = await result.toPng();
+      const imageUrl = imgElement.src;
+
+      if (onSaveImage) {
+        onSaveImage(imageUrl);
+      }
+    } catch {
+      alert('生成图片失败,请重试');
+      const saveButton = resultRef.current?.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current?.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+    }
+  };
+
+  return (
+    <div
+      ref={resultRef}
+      className="result-card"
+      style={{ background: gradientStyle }}
+    >
+      <div className="result-content">
+        <div className="flex justify-center items-center" style={{ marginBottom: '1rem', background: 'transparent' }}>
+          <img src="/questionnaire-title.svg" width={300} height={70} alt="Logo" style={{ display: 'block', background: 'transparent' }} />
+        </div>
+
+        {/* 基本信息 */}
+        <div className="result-item">
+          <div className="result-label">💖 魔法少女代号</div>
+          <div className="result-value">{magicalGirl.codename}</div>
+        </div>
+
+        {/* 外观描述 */}
+        <div className="result-item">
+          <div className="result-label">🎀 魔法少女外观</div>
+          <div className="result-value">
+            <div><strong>战斗服:</strong>{magicalGirl.appearance.outfit}</div>
+            <div><strong>饰品:</strong>{magicalGirl.appearance.accessories}</div>
+            <div><strong>主色调:</strong>{magicalGirl.appearance.colorScheme}</div>
+            <div><strong>整体气质:</strong>{magicalGirl.appearance.overallLook}</div>
+          </div>
+        </div>
+
+        {/* 魔法武器 */}
+        <div className="result-item">
+          <div className="result-label">🌟 魔法武器</div>
+          <div className="result-value">
+            <div><strong>名称:</strong>{magicalGirl.magicWeapon.name}</div>
+            <div><strong>形态:</strong>{magicalGirl.magicWeapon.form}</div>
+            <div><strong>核心能力:</strong></div>
+            <ul style={{ marginLeft: '1rem', marginTop: '0.5rem' }}>
+              {magicalGirl.magicWeapon.basicAbilities.map((ability: string, index: number) => (
+                <li key={index}>✨ {ability}</li>
+              ))}
+            </ul>
+            <div style={{ marginTop: '0.5rem' }}><strong>背景:</strong>{magicalGirl.magicWeapon.description}</div>
+          </div>
+        </div>
+
+        {/* 必杀技 */}
+        <div className="result-item">
+          <div className="result-label">🌠 必杀技</div>
+          <div className="result-value">
+            <div><strong>名称:</strong>{magicalGirl.specialMove.name}</div>
+            <div style={{ marginTop: '0.5rem' }}><strong>咏唱:</strong><span style={{ fontStyle: 'italic' }}>「{magicalGirl.specialMove.chant}」</span></div>
+            <div style={{ marginTop: '0.5rem' }}><strong>效果:</strong>{magicalGirl.specialMove.description}</div>
+          </div>
+        </div>
+
+        {/* 觉醒形态 */}
+        <div className="result-item">
+          <div className="result-label">🌌 觉醒形态</div>
+          <div className="result-value">
+            <div><strong>形态名称:</strong>{magicalGirl.awakening.name}</div>
+            <div><strong>进化能力:</strong></div>
+            <ul style={{ marginLeft: '1rem', marginTop: '0.5rem' }}>
+              {magicalGirl.awakening.evolvedAbilities.map((ability: string, index: number) => (
+                <li key={index}>🌠 {ability}</li>
+              ))}
+            </ul>
+            <div><strong>武器进化:</strong>{magicalGirl.awakening.evolvedForm}</div>
+            <div><strong>服装进化:</strong>{magicalGirl.awakening.evolvedOutfit}</div>
+          </div>
+        </div>
+
+        {/* 综合分析 */}
+        <div className="result-item">
+          <div className="result-label">🧠 综合分析</div>
+          <div className="result-value">
+            <div><strong>性格分析:</strong>{magicalGirl.analysis.personalityAnalysis}</div>
+            <div><strong>能力设定思路:</strong>{magicalGirl.analysis.abilityReasoning}</div>
+            <div><strong>核心萌属性:</strong>{magicalGirl.analysis.coreTraits.join('、')}</div>
+            <div><strong>创作依据:</strong>{magicalGirl.analysis.predictionBasis}</div>
+          </div>
+        </div>
+
+        <button onClick={handleSaveImage} className="save-button">
+          📱 保存为图片
+        </button>
+
+        <div className="logo-placeholder" style={{ display: 'none', justifyContent: 'center', marginTop: '1rem' }}>
+          <img
+            src="/logo-white-qrcode.svg"
+            width={320}
+            height={80}
+            alt="Logo"
+            style={{
+              display: 'block',
+              maxWidth: '100%',
+              height: 'auto'
+            }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default MagicalGirlCardACG;

+ 20 - 0
config/ai-providers.example.json

@@ -0,0 +1,20 @@
+[
+  {
+    "name": "primary_openai",
+    "apiKey": "your_openai_api_key_here",
+    "baseUrl": "https://api.openai.com/v1",
+    "model": "gpt-4"
+  },
+  {
+    "name": "gemini_provider",
+    "apiKey": "your_gemini_api_key_here",
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
+    "model": "gemini-2.0-flash"
+  },
+  {
+    "name": "backup_provider",
+    "apiKey": "your_backup_api_key",
+    "baseUrl": "https://api.backup-provider.com/v1",
+    "model": "gpt-3.5-turbo"
+  }
+]

+ 41 - 0
env.example

@@ -0,0 +1,41 @@
+# 修改为 .env.local 来使用捏
+
+# AI 提供商配置(JSON 格式)
+# 按顺序配置多个提供商,每个配置一个模型
+AI_PROVIDERS_CONFIG='[
+  {
+    "name": "primary_openai",
+    "apiKey": "your_openai_api_key_here",
+    "baseUrl": "https://api.openai.com/v1",
+    "model": "gpt-4",
+    "type": "openai",
+    "retryCount": 2,
+    "skipProbability": 0,
+    "mode": "json" // 用于适配一些无法正常识别工具的模型
+  },
+  {
+    "name": "gemini_provider",
+    "apiKey": "your_gemini_api_key_here",
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
+    "model": "gemini-2.0-flash",
+    "type": "google",
+    "retryCount": 1,
+    "skipProbability": 0.1
+  },
+  {
+    "name": "backup_provider",
+    "apiKey": "your_backup_api_key",
+    "baseUrl": "https://api.backup-provider.com/v1",
+    "model": "gpt-3.5-turbo",
+    "type": "openai",
+    "retryCount": 1,
+    "skipProbability": 0.3
+  }
+]'
+
+# ===== 向后兼容配置(不推荐,建议使用上面的 JSON 配置) =====
+# AI_API_KEY=your_openai_api_key_here
+# AI_BASE_URL=https://api.openai.com/v1
+# AI_MODEL=gemini-2.0-flash 
+
+NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX

+ 16 - 0
eslint.config.mjs

@@ -0,0 +1,16 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const compat = new FlatCompat({
+  baseDirectory: __dirname,
+});
+
+const eslintConfig = [
+  ...compat.extends("next/core-web-vitals", "next/typescript"),
+];
+
+export default eslintConfig;

+ 116 - 0
lib/ai.ts

@@ -0,0 +1,116 @@
+import { generateObject, NoObjectGeneratedError } from "ai";
+import { createOpenAI } from "@ai-sdk/openai";
+import { createGoogleGenerativeAI } from "@ai-sdk/google";
+import { z } from "zod";
+import { config, AIProvider } from "./config";
+
+// 延迟函数
+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+// 生成配置接口
+export interface GenerationConfig<T, I = string> {
+  systemPrompt: string;
+  temperature: number;
+  promptBuilder: (input: I) => string;
+  schema: z.ZodSchema<T>;
+  taskName: string;
+  maxTokens: number;
+}
+
+const createAIClient = (provider: AIProvider) => {
+  if (provider.type === 'google') {
+    return createGoogleGenerativeAI({
+      apiKey: provider.apiKey,
+      baseURL: provider.baseUrl,
+    });
+  } else {
+    return createOpenAI({
+      apiKey: provider.apiKey,
+      baseURL: provider.baseUrl,
+      compatibility: "compatible",
+    });
+  }
+};
+
+// 通用 AI 生成函数
+export async function generateWithAI<T, I = string>(
+  input: I,
+  generationConfig: GenerationConfig<T, I>
+): Promise<T> {
+  const providers = config.PROVIDERS;
+
+  if (providers.length === 0) {
+    console.error("没有配置 API Key");
+    throw new Error("没有配置 API Key");
+  }
+
+  let lastError: unknown = null;
+
+  // 遍历所有提供商
+  for (let providerIndex = 0; providerIndex < providers.length; providerIndex++) {
+    const provider = providers[providerIndex];
+
+    // 检查是否跳过此提供商(第一个提供商不跳过)
+    if (providerIndex > 0 && Math.random() < (provider.skipProbability ?? 0)) {
+      console.log(`跳过提供商: ${provider.name} (跳过概率: ${provider.skipProbability})`);
+      continue;
+    }
+
+    const retryCount = provider.retryCount ?? 1;
+    console.log(`使用提供商: ${provider.name},模型: ${provider.model},重试次数: ${retryCount}`);
+
+    // 对当前提供商进行重试
+    for (let attempt = 0; attempt < retryCount; attempt++) {
+      try {
+        console.log(`提供商 ${provider.name} 第 ${attempt + 1}/${retryCount} 次尝试`);
+
+        const llm = createAIClient(provider);
+
+        const generateOptions = {
+          model: llm(provider.model),
+          system: generationConfig.systemPrompt,
+          prompt: generationConfig.promptBuilder(input),
+          schema: generationConfig.schema,
+          temperature: generationConfig.temperature,
+          maxTokens: generationConfig.maxTokens,
+          retryCount: 1,
+          mode: provider.mode || 'auto',
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
+          experimental_repairText: provider.mode === 'json' ? async (options: any) => {
+            options.text = options.text.replace('```json\n', '').replace('\n```', '');
+            return options.text;
+          } : undefined,
+        };
+
+        const { object } = await generateObject(generateOptions);
+
+        console.log(`提供商 ${provider.name} 第 ${attempt + 1} 次尝试成功`);
+        return object as T;
+      } catch (error) {
+        lastError = error;
+        console.error(`提供商 ${provider.name} 第 ${attempt + 1} 次尝试失败:`, error);
+
+        if (NoObjectGeneratedError.isInstance(error)) {
+          console.log("NoObjectGeneratedError 详情:");
+          console.log("Cause:", error.cause);
+          console.log("Text:", error.text);
+          console.log("Response:", error.response);
+          console.log("Usage:", error.usage);
+          console.log("Finish Reason:", error.finishReason);
+        }
+
+        // 如果不是最后一次尝试,等待后再重试
+        if (attempt < retryCount - 1) {
+          const waitTime = (attempt + 1) * 200; // 递增等待时间
+          console.log(`等待 ${waitTime} 毫秒后重试...`);
+          await sleep(waitTime);
+        }
+      }
+    }
+
+    console.log(`提供商 ${provider.name} 所有尝试都失败了`);
+  }
+
+  console.error("所有提供商都失败了:", lastError);
+  throw new Error(`${generationConfig.taskName}失败: ${lastError}`);
+}

+ 97 - 0
lib/config.ts

@@ -0,0 +1,97 @@
+// AI 提供商配置接口
+export interface AIProvider {
+  name: string;
+  apiKey: string;
+  baseUrl: string;
+  model: string;
+  type: 'openai' | 'google';
+  retryCount?: number;
+  skipProbability?: number;
+  mode?: 'json' | 'auto' | 'tool' | undefined;
+}
+
+// 解析 AI 提供商配置的函数
+const parseAIProviders = (): AIProvider[] => {
+  // JSON 配置方式
+  if (process.env.AI_PROVIDERS_CONFIG) {
+    try {
+      const providers = JSON.parse(process.env.AI_PROVIDERS_CONFIG) as AIProvider[];
+      return providers
+        .filter(p => p.apiKey && p.baseUrl && p.model && p.type)
+        .map(p => ({
+          ...p,
+          retryCount: p.retryCount ?? 1,
+          skipProbability: p.skipProbability ?? 0
+        }));
+    } catch (error) {
+      console.warn('解析 AI_PROVIDERS_CONFIG 失败,回退到简单配置:', error);
+    }
+  }
+
+  // 向后兼容:单个 API Key 方式
+  const singleKey = process.env.AI_API_KEY;
+  const singleUrl = process.env.AI_BASE_URL || 'https://api.openai.com/v1';
+  const singleModel = process.env.AI_MODEL || 'gemini-2.0-flash';
+
+  if (singleKey) {
+    return [{
+      name: 'default_provider',
+      apiKey: singleKey,
+      baseUrl: singleUrl,
+      model: singleModel,
+      type: singleUrl.includes('googleapis.com') ? 'google' : 'openai',
+      retryCount: 1,
+      skipProbability: 0
+    }];
+  }
+
+  return [];
+};
+
+// 获取有效的 API 提供商(按配置顺序)
+const getAPIProviders = (): AIProvider[] => {
+  return parseAIProviders();
+};
+
+// 为了保持向后兼容,转换为原有的格式
+const parseApiPairs = () => {
+  const providers = getAPIProviders();
+  return providers.map(provider => ({
+    apiKey: provider.apiKey,
+    baseUrl: provider.baseUrl,
+    name: provider.name,
+    model: provider.model,
+    mode: provider?.mode || 'auto'
+  }));
+};
+
+// 获取第一个提供商的模型
+const getDefaultModel = (): string => {
+  const providers = getAPIProviders();
+  if (providers.length > 0) {
+    return providers[0].model;
+  }
+  return 'gemini-2.5-flash';
+};
+
+export const config = {
+  // Vercel AI 配置
+  API_PAIRS: parseApiPairs(),
+  MODEL: getDefaultModel(),
+  PROVIDERS: getAPIProviders(),
+
+  // 魔法少女生成配置
+  MAGICAL_GIRL_GENERATION: {
+    temperature: 0.8,
+
+    // 系统提示词
+    systemPrompt: `你是一个专业的魔法少女角色设计师。请根据用户输入的真实姓名,设计一个独特的魔法少女角色。
+
+设计要求:
+1. 魔法少女名字应该以花名为主题,要与用户的真实姓名有某种关联性或呼应
+2. 外貌特征要协调统一,符合魔法少女的设定
+3. 变身咒语要朗朗上口,充满魔法感
+
+请严格按照提供的 JSON schema 格式返回结果。`
+  }
+} 

+ 61 - 0
lib/cooldown.ts

@@ -0,0 +1,61 @@
+import { useState, useEffect, useCallback } from 'react';
+
+const getLocalStorageItem = (key: string): number | null => {
+    if (typeof window === 'undefined') {
+        return null;
+    }
+    const item = localStorage.getItem(key);
+    return item ? parseInt(item, 10) : null;
+};
+
+const setLocalStorageItem = (key: string, value: number) => {
+    if (typeof window === 'undefined') {
+        return;
+    }
+    localStorage.setItem(key, value.toString());
+};
+
+export const useCooldown = (key: string, duration: number) => {
+    // 在开发环境中禁用 cooldown
+    const isDevelopment = process.env.NODE_ENV === 'development';
+    
+    const [cooldownEndTime, setCooldownEndTime] = useState<number | null>(() => 
+        isDevelopment ? null : getLocalStorageItem(key)
+    );
+    const [remainingTime, setRemainingTime] = useState<number>(0);
+
+    useEffect(() => {
+        if (isDevelopment || !cooldownEndTime) return;
+
+        const calculateRemainingTime = () => {
+            const now = Date.now();
+            const remaining = cooldownEndTime - now;
+            if (remaining <= 0) {
+                setRemainingTime(0);
+                setCooldownEndTime(null);
+                localStorage.removeItem(key);
+            } else {
+                setRemainingTime(Math.ceil(remaining / 1000));
+            }
+        };
+
+        calculateRemainingTime();
+
+        const interval = setInterval(calculateRemainingTime, 1000);
+
+        return () => clearInterval(interval);
+    }, [cooldownEndTime, key, isDevelopment]);
+
+    const startCooldown = useCallback(() => {
+        // 在开发环境中不启动 cooldown
+        if (isDevelopment) return;
+        
+        const endTime = Date.now() + duration;
+        setLocalStorageItem(key, endTime);
+        setCooldownEndTime(endTime);
+    }, [duration, key, isDevelopment]);
+
+    const isCooldown = !isDevelopment && remainingTime > 0;
+
+    return { isCooldown, startCooldown, remainingTime };
+};

+ 10 - 0
lib/main-color.ts

@@ -0,0 +1,10 @@
+export const MainColor = {
+    Red: '红色',
+    Orange: '橙色',
+    Cyan: '青色',
+    Blue: '蓝色',
+    Purple: '紫色',
+    Pink: '粉色',
+    Yellow: '黄色',
+    Green: '绿色'
+} as const;

+ 4 - 0
lib/random-choose-hana-name.ts

@@ -0,0 +1,4 @@
+export const randomChooseHanaName = () => {
+    const hanaNames = [''];
+    return hanaNames[Math.floor(Math.random() * hanaNames.length)];
+};

+ 168 - 0
lib/rate-limiter.ts

@@ -0,0 +1,168 @@
+// IP限制工具类
+interface RateLimitRecord {
+  count: number;
+  firstRequest: number;
+  endpoint: string;
+}
+
+class RateLimiter {
+  private records: Map<string, RateLimitRecord[]> = new Map();
+  private readonly windowMs = 120 * 1000; // 1分钟
+  private readonly maxRequests = 1; // 每个API每分钟最多1次
+
+  /**
+   * 清理过期记录
+   */
+  private cleanup(): void {
+    const now = Date.now();
+    const entries = Array.from(this.records.entries());
+    for (const [ip, requests] of entries) {
+      const validRequests = requests.filter(
+        (record: RateLimitRecord) => now - record.firstRequest < this.windowMs
+      );
+      if (validRequests.length === 0) {
+        this.records.delete(ip);
+      } else {
+        this.records.set(ip, validRequests);
+      }
+    }
+  }
+
+  /**
+   * 检查IP是否被限制
+   */
+  public isRateLimited(ip: string, endpoint: string): boolean {
+    this.cleanup();
+
+    const requests = this.records.get(ip) || [];
+    const now = Date.now();
+
+    // 过滤出时间窗口内的请求
+    const validRequests = requests.filter(
+      record => now - record.firstRequest < this.windowMs
+    );
+
+    // 检查该IP在指定endpoint上的请求次数
+    const endpointRequests = validRequests.filter(record => record.endpoint === endpoint);
+
+    if (endpointRequests.length >= this.maxRequests) {
+      return true;
+    }
+
+    // 检查该IP的总请求次数是否超过2(两个API各一次)
+    const totalEndpoints = new Set(validRequests.map(record => record.endpoint));
+    if (totalEndpoints.size >= 2 && !totalEndpoints.has(endpoint)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * 记录请求
+   */
+  public recordRequest(ip: string, endpoint: string): void {
+    const requests = this.records.get(ip) || [];
+    const now = Date.now();
+
+    requests.push({
+      count: 1,
+      firstRequest: now,
+      endpoint
+    });
+
+    this.records.set(ip, requests);
+  }
+
+  /**
+   * 获取剩余时间(毫秒)
+   */
+  public getTimeUntilReset(ip: string, endpoint: string): number {
+    const requests = this.records.get(ip) || [];
+    const endpointRequests = requests.filter(record => record.endpoint === endpoint);
+
+    if (endpointRequests.length === 0) {
+      return 0;
+    }
+
+    const oldestRequest = Math.min(...endpointRequests.map(record => record.firstRequest));
+    const resetTime = oldestRequest + this.windowMs;
+
+    return Math.max(0, resetTime - Date.now());
+  }
+}
+
+// 全局实例
+const rateLimiter = new RateLimiter();
+
+/**
+ * 获取客户端真实IP地址
+ */
+type RequestWithIP = {
+  headers: Record<string, string | string[] | undefined>;
+  connection?: { remoteAddress?: string };
+  socket?: { remoteAddress?: string };
+  body?: unknown;
+};
+
+export function getClientIP(req: RequestWithIP): string {
+  const forwarded = req.headers['x-forwarded-for'];
+  const realIP = req.headers['x-real-ip'];
+  const remoteAddress = req.connection?.remoteAddress || req.socket?.remoteAddress;
+
+  if (typeof forwarded === 'string') {
+    return forwarded.split(',')[0].trim();
+  }
+
+  if (typeof realIP === 'string') {
+    return realIP;
+  }
+
+  return remoteAddress || 'unknown';
+}
+
+/**
+ * IP限制中间件
+ */
+export function withRateLimit(endpoint: string) {
+  return function <T extends RequestWithIP, U>(handler: (req: T, res: U) => Promise<unknown>) {
+    return async function (req: T, res: U & { status: (code: number) => { json: (data: unknown) => unknown } }) {
+      try {
+        const ip = getClientIP(req);
+        console.log(`[Rate Limiter] IP: ${ip}, Endpoint: ${endpoint}`);
+
+        // 检查IP是否被限制
+        if (rateLimiter.isRateLimited(ip, endpoint)) {
+          const timeUntilReset = rateLimiter.getTimeUntilReset(ip, endpoint);
+          const resetTime = Math.ceil(timeUntilReset / 1000);
+
+          console.log(`[Rate Limiter] 限制访问 - IP: ${ip}, Endpoint: ${endpoint}, 重置时间: ${resetTime}秒`);
+          
+          // 打印请求的 body(如果是 POST 请求)
+          if (req.body) {
+            console.log(`[Rate Limiter] 被限制的请求 body:`, JSON.stringify(req.body, null, 2));
+          }
+
+          return res.status(429).json({
+            error: '请求过于频繁',
+            message: `同一IP每分钟只能调用每个API一次,请等待 ${resetTime} 秒后再试`,
+            retryAfter: resetTime
+          });
+        }
+
+        // 记录请求
+        rateLimiter.recordRequest(ip, endpoint);
+        console.log(`[Rate Limiter] 记录请求 - IP: ${ip}, Endpoint: ${endpoint}`);
+
+        // 继续执行原有的处理函数
+        return await handler(req, res);
+      } catch (error) {
+        console.error(`[Rate Limiter] 错误:`, error);
+        // 如果 rate limiter 出错,继续执行原有的处理函数
+        return await handler(req, res);
+      }
+    };
+  };
+}
+
+export default rateLimiter;

+ 287 - 0
lib/sensitive-word-filter.ts

@@ -0,0 +1,287 @@
+// 防止屏蔽,所以用base64编码并直接扔文件里
+const sensitiveWordsConfig = {
+    mask: "*",
+    mask_word: "",
+    words: [
+        "5Lmg6L+R5bmz",
+        "6IOh6ZSm5rab",
+        "5rGf5rO95rCR",
+        "5rip5a625a6d",
+        "5p2O5YWL5by6",
+        "5p2O6ZW/5pil",
+        "5q+b5rO95Lic",
+        "6YKT5bCP5bmz",
+        "5ZGo5oGp5p2l",
+        "6ams5YWL5oCd",
+        "56S+5Lya5Li75LmJ",
+        "5YWx5Lqn5YWa",
+        "5YWx5Lqn5Li75LmJ",
+        "5aSn6ZmG5a6Y5pa5",
+        "5YyX5Lqs5pS/5p2D",
+        "5Lit5Y2O5bid5Zu9",
+        "5Lit5Zu95pS/5bqc",
+        "5YWx54uX",
+        "5YWt5Zub5LqL5Lu2",
+        "5aSp5a6J6Zeo",
+        "5YWt5Zub",
+        "5pS/5rK75bGA5bi45aeU",
+        "5Lik5Lya",
+        "5YWx6Z2S5Zui",
+        "5a2m5r2u",
+        "5YWr5Lmd",
+        "5LqM5Y2B5aSn",
+        "5rCR6L+b5YWa",
+        "5Y+w54us",
+        "5Y+w5rm+54us56uL",
+        "5Y+w5rm+5Zu9",
+        "5Zu95rCR5YWa",
+        "5Y+w5rm+5rCR5Zu9",
+        "5Lit5Y2O5rCR5Zu9",
+        "cG9ybmh1Yg==",
+        "UG9ybmh1Yg==",
+        "W1l5XW91W1BwXW9ybg==",
+        "cG9ybg==",
+        "UG9ybg==",
+        "W1h4XVtWdl1pZGVvcw==",
+        "W1JyXWVkW1R0XXViZQ==",
+        "W1h4XVtIaF1hbXN0ZXI=",
+        "W1NzXXBhbmtbV3ddaXJl",
+        "W1NzXXBhbmtbQmJdYW5n",
+        "W1R0XXViZTg=",
+        "W1l5XW91W0pqXWl6eg==",
+        "W0JiXXJhenplcnM=",
+        "W05uXWF1Z2h0eVsgXT9bQWFdbWVyaWNh",
+        "5L2c54ix",
+        "5YGa54ix",
+        "5oCn5Lqk",
+        "5oCn54ix",
+        "6Ieq5oWw",
+        "6Zi06IyO",
+        "5rer5aaH",
+        "6IKb5Lqk",
+        "5Lqk6YWN",
+        "5oCn5YWz57O7",
+        "5oCn5rS75Yqo",
+        "6Imy5oOF",
+        "6Imy5Zu+",
+        "5rap5Zu+",
+        "6KO45L2T",
+        "5bCP56m0",
+        "5rer6I2h",
+        "5oCn54ix",
+        "57+75aKZ",
+        "VlBO",
+        "56eR5a2m5LiK572R",
+        "5oyC5qKv5a2Q",
+        "R0ZX"
+    ],
+    encoding: "base64",
+    original_count: 71
+}
+
+/**
+ * 敏感词过滤结果接口
+ */
+interface FilterResult {
+    /** 是否包含敏感词 */
+    hasSensitiveWords: boolean;
+    /** 检测到的敏感词列表 */
+    detectedWords: string[];
+    /** 过滤后的文本(敏感词被替换) */
+    filteredText: string;
+    /** 原始文本 */
+    originalText: string;
+    /** 是否需要跳转到被捕页面 */
+    shouldRedirectToArrested: boolean;
+}
+
+/**
+ * 敏感词配置接口
+ */
+interface SensitiveWordsConfig {
+    mask: string;
+    mask_word: string;
+    words: string[];
+    encoding?: string;
+    original_count?: number;
+}
+
+/**
+ * 敏感词过滤器类
+ */
+export class SensitiveWordFilter {
+    private sensitiveWords: string[] = [];
+    private config: SensitiveWordsConfig | null = null;
+    private isInitialized: boolean = false;
+
+    constructor() {
+        this.config = sensitiveWordsConfig;
+        this.initialize();
+    }
+
+    /**
+     * 初始化敏感词列表
+     */
+    async initialize(): Promise<boolean> {
+        try {
+            if (!this.config || !this.config.words) {
+                throw new Error('配置文件格式错误');
+            }
+
+            // 如果是编码后的文件,需要解码
+            if (this.config.encoding === 'base64') {
+                this.sensitiveWords = this.config.words.map(word =>
+                    Buffer.from(word, 'base64').toString('utf8')
+                );
+            } else {
+                this.sensitiveWords = [...this.config.words];
+            }
+
+            this.isInitialized = true;
+            console.log(`✅ 敏感词过滤器初始化成功,加载了 ${this.sensitiveWords.length} 个敏感词`);
+            return true;
+        } catch (error) {
+            console.error('❌ 初始化敏感词过滤器失败:', error);
+            return false;
+        }
+    }
+
+    /**
+     * 检查文本中是否包含敏感词
+     */
+    checkText(text: string): FilterResult {
+        if (!this.isInitialized) {
+            throw new Error('过滤器未初始化,请先调用 initialize() 方法');
+        }
+
+        const detectedWords: string[] = [];
+        let filteredText = text;
+
+        // 检查每个敏感词
+        for (const word of this.sensitiveWords) {
+            // 处理正则表达式格式的敏感词
+            const isRegex = word.includes('[') || word.includes('(') || word.includes('|');
+
+            if (isRegex) {
+                try {
+                    const regex = new RegExp(word, 'gi');
+                    const matches = text.match(regex);
+                    if (matches) {
+                        matches.forEach(match => {
+                            if (!detectedWords.includes(match)) {
+                                detectedWords.push(match);
+                            }
+                        });
+                        // 替换敏感词
+                        filteredText = filteredText.replace(regex, this.getMaskString());
+                    }
+                } catch (regexError) {
+                    console.error('正则表达式格式错误:', regexError);
+                    // 如果正则表达式格式错误,按普通字符串处理
+                    if (text.toLowerCase().includes(word.toLowerCase())) {
+                        detectedWords.push(word);
+                        filteredText = this.replaceSensitiveWord(filteredText, word);
+                    }
+                }
+            } else {
+                // 普通字符串匹配(忽略大小写)
+                if (text.toLowerCase().includes(word.toLowerCase())) {
+                    detectedWords.push(word);
+                    filteredText = this.replaceSensitiveWord(filteredText, word);
+                }
+            }
+        }
+
+        const hasSensitiveWords = detectedWords.length > 0;
+
+        return {
+            hasSensitiveWords,
+            detectedWords,
+            filteredText,
+            originalText: text,
+            shouldRedirectToArrested: hasSensitiveWords
+        };
+    }
+
+    /**
+     * 替换敏感词
+     */
+    private replaceSensitiveWord(text: string, word: string): string {
+        if (!this.config) return text;
+
+        const regex = new RegExp(word, 'gi');
+
+        if (this.config.mask_word && this.config.mask_word.trim() !== '') {
+            // 如果设置了完整替换词,用它替换整个敏感词
+            return text.replace(regex, this.config.mask_word);
+        } else {
+            // 否则用mask字符替换敏感词的每个字符
+            return text.replace(regex, (match) => this.config!.mask.repeat(match.length));
+        }
+    }
+
+    /**
+     * 获取遮罩字符串
+     */
+    private getMaskString(): (match: string) => string {
+        return (match: string) => {
+            if (this.config?.mask_word && this.config.mask_word.trim() !== '') {
+                return this.config.mask_word;
+            } else {
+                return (this.config?.mask || '*').repeat(match.length);
+            }
+        };
+    }
+
+    /**
+     * 批量检查文本数组
+     */
+    checkTextArray(texts: string[]): FilterResult[] {
+        return texts.map(text => this.checkText(text));
+    }
+
+    /**
+     * 检查文本并决定是否跳转
+     */
+    async checkAndRedirect(text: string, redirectCallback?: () => void): Promise<FilterResult> {
+        const result = this.checkText(text);
+
+        if (result.shouldRedirectToArrested) {
+            console.warn(`🚨 检测到敏感词: ${result.detectedWords.join(', ')}`);
+
+            if (redirectCallback) {
+                redirectCallback();
+            } else {
+                // 如果在浏览器环境中
+                if (typeof window !== 'undefined' && window.location) {
+                    window.location.href = '/arrested';
+                } else {
+                    console.log('🚨 应该跳转到 /arrested 页面');
+                }
+            }
+        }
+
+        return result;
+    }
+}
+
+/**
+ * 创建默认的敏感词过滤器实例
+ */
+export const createSensitiveWordFilter = (): SensitiveWordFilter => {
+    return new SensitiveWordFilter();
+};
+
+
+/**
+ * 快速检查并跳转函数
+ */
+export const quickCheck = async (
+    text: string
+): Promise<FilterResult> => {
+    const filter = createSensitiveWordFilter();
+    return filter.checkText(text);
+};
+
+
+export default SensitiveWordFilter;

+ 18 - 0
next.config.ts

@@ -0,0 +1,18 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  // 图片优化配置(Cloudflare Pages 不支持默认的图片优化)
+  images: {
+    unoptimized: true
+  },
+  
+  // 其他配置
+  typescript: {
+    ignoreBuildErrors: false,
+  },
+  eslint: {
+    ignoreDuringBuilds: false,
+  },
+};
+
+export default nextConfig;

Разлика између датотеке није приказан због своје велике величине
+ 12014 - 0
package-lock.json


+ 41 - 0
package.json

@@ -0,0 +1,41 @@
+{
+    "name": "if-mahou-shoujo",
+    "version": "0.1.0",
+    "private": true,
+    "scripts": {
+        "dev": "next dev --turbopack",
+        "build": "next build",
+        "build:cf": "next-on-pages",
+        "preview": "npm run build:cf && wrangler pages dev",
+        "start": "next start",
+        "lint": "next lint"
+    },
+    "dependencies": {
+        "@ai-sdk/google": "^1.2.22",
+        "@ai-sdk/openai": "^1.3.22",
+        "@next/third-parties": "^15.4.5",
+        "@zumer/snapdom": "^1.9.7",
+        "ai": "^4.3.16",
+        "class-variance-authority": "^0.7.1",
+        "clsx": "^2.1.1",
+        "lucide-react": "^0.511.0",
+        "next": "15.3.2",
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0",
+        "tailwind-merge": "^3.3.0",
+        "zod": "^3.25.67"
+    },
+    "devDependencies": {
+        "@cloudflare/next-on-pages": "^1.13.13",
+        "@eslint/eslintrc": "^3",
+        "@tailwindcss/postcss": "^4",
+        "@types/node": "^20",
+        "@types/react": "^19",
+        "@types/react-dom": "^19",
+        "eslint": "^9",
+        "eslint-config-next": "15.3.2",
+        "tailwindcss": "^4",
+        "tw-animate-css": "^1.3.0",
+        "typescript": "^5"
+    }
+}

+ 31 - 0
pages/_app.tsx

@@ -0,0 +1,31 @@
+import type { AppProps } from 'next/app';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import '@/styles/globals.css';
+import '@/styles/blue-theme.css';
+import { GoogleAnalytics } from '@next/third-parties/google';
+
+// 如果需要统计,请取消注释并安装 @vercel/analytics
+// import { Analytics } from "@vercel/analytics/next";
+
+export default function App({ Component, pageProps }: AppProps) {
+  const router = useRouter();
+  const isDetailsPage = router.pathname === '/details';
+
+  return (
+    <>
+      <Head>
+        <title>✨ 魔法少女生成器 ✨</title>
+        <meta name="description" content="为你生成独特的魔法少女角色" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <link rel="icon" href="/favicon.svg" />
+      </Head>
+
+      <div className={isDetailsPage ? 'blue-theme' : ''}>
+        <Component {...pageProps} />
+        <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID || ''} />
+        {/* <Analytics /> */}
+      </div>
+    </>
+  );
+} 

+ 536 - 0
pages/acg.tsx

@@ -0,0 +1,536 @@
+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>
+            </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 能力由&nbsp;
+              <a href="https://github.com/KouriChat/KouriChat" target="_blank" rel="noopener noreferrer" className="footer-link">KouriChat</a> &&nbsp;
+              <a href="https://api.kourichat.com/" target="_blank" rel="noopener noreferrer" className="footer-link">Kouri API</a>
+              &nbsp;强力支持
+            </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;

Разлика између датотеке није приказан због своје велике величине
+ 150 - 0
pages/api/generate-magical-girl-acg.ts


Разлика између датотеке није приказан због своје велике величине
+ 141 - 0
pages/api/generate-magical-girl-details.ts


+ 94 - 0
pages/api/generate-magical-girl.ts

@@ -0,0 +1,94 @@
+import { z } from "zod";
+import { generateWithAI, GenerationConfig } from "../../lib/ai";
+import { config as appConfig } from "../../lib/config";
+import { MainColor } from "../../lib/main-color";
+
+export const config = {
+  runtime: 'edge',
+};
+
+export type MainColor = (typeof MainColor)[keyof typeof MainColor];
+
+// 定义魔法少女生成的 schema(排除 level 相关字段)
+const MagicalGirlGenerationSchema = z.object({
+  flowerName: z.string().describe(`魔法少女的花名,应该与真实姓名有一定关联,如果真实姓名中有花则大概率用名字中的花名。
+    必须是一种花比如百合 / 丁香 / 茉莉,可以增加冷门的小众的花名概率,减少鸢尾的出现次数,大部分时候输出常用中文名,有时候可以使用英文音译为中文或者拉丁文音译为中文增加酷炫度,
+    但是不要出现魔法少女字样`),
+  flowerDescription: z.string().describe("生成的 flowerName 在大众文化中的花语,大概 20 字左右,不要出现魔法少女字样"),
+  appearance: z.object({
+    height: z.string().describe('身高,格式如 "155cm",数据在 130cm 到 190cm 之间,减少使用 165cm,参考角色设定来生成'),
+    weight: z.string().describe('体重,格式如 "45kg",数据在 30kg 到 60kg 之间,减少使用 48kg,参考角色设定来生成'),
+    hairColor: z.string().describe("头发颜色,会出现渐变和挑染"),
+    hairStyle: z.string().describe(`发型,具体到头发长度、发型样式、发饰等,可以是各种各样形状和颜色的发卡,
+      发挥你的想象力,符合审美即可,尽量不出现花形状的发饰,也可能是帽子、发卡、发箍之类的`),
+    eyeColor: z.string().describe("眼睛颜色,有几率出现异瞳,比如一只蓝色一只绿色"),
+    skinTone: z.string().describe("肤色,通常是白皙,但是偶尔会出现其他肤色,根据人物设定生成"),
+    wearing: z.string().describe('人物身穿的服装样式,需要描述具体的颜色和式样款式,一般比较华丽,不要拘泥于花形状,符合主色调即可,其他形制在符合花语的情况下自由发挥'),
+    specialFeature: z.string().describe("特征,一般是反映人物性格的常见表情、动作、特征等"),
+    mainColor: z.enum(Object.values(MainColor) as [string, ...string[]]).describe(
+      `魔法少女的主色调,请参考 hairColor 选择最接近的一项,如果 hairColor 是渐变,请选择最接近的渐变主色调`),
+    firstPageColor: z.string().describe("根据 mainColor 产生第一个渐变色,格式以 #000000 给出"),
+    secondPageColor: z.string().describe("根据 mainColor 产生第二个渐变色,格式以 #000000 给出"),
+  }),
+  spell: z.string().describe(`很酷的变身咒语,提供日语版和对应的中文翻译,使用 \n 换行,参考常见的日本魔法少女中的变身,通常 20 字到 40 字左右。
+    - 参考格式1:
+        "黒よりも黒く、闇よりも暗い。ここに我が真の真紅の黄金の光を託す。目覚めの時が来た。不条理な教会の腐敗した論理" \n
+        "比黑色更黑 比黑暗更暗的漆黑 在此寄讬吾真红的金光吧 觉醒之时的到来 荒谬教会的堕落章理"`),
+});
+
+export type AIGeneratedMagicalGirl = z.infer<
+  typeof MagicalGirlGenerationSchema
+>;
+
+// 魔法少女生成配置
+const magicalGirlGenerationConfig: GenerationConfig<AIGeneratedMagicalGirl, string> = {
+  systemPrompt: appConfig.MAGICAL_GIRL_GENERATION.systemPrompt,
+  temperature: appConfig.MAGICAL_GIRL_GENERATION.temperature,
+  promptBuilder: (realName: string) => `请为名叫"${realName}"的人设计一个魔法少女角色。真实姓名:${realName}`,
+  schema: MagicalGirlGenerationSchema,
+  taskName: "生成魔法少女",
+  maxTokens: 6000,
+};
+
+// 生成魔法少女的函数(使用通用函数)
+export async function generateMagicalGirlWithAI(
+  realName: string
+): Promise<AIGeneratedMagicalGirl> {
+  return generateWithAI(realName, magicalGirlGenerationConfig);
+}
+
+async function handler(
+  req: Request
+): Promise<Response> {
+  if (req.method !== 'POST') {
+    return new Response(JSON.stringify({ error: 'Method not allowed' }), {
+      status: 405,
+      headers: { 'Content-Type': 'application/json' }
+    });
+  }
+
+  const { name } = await req.json();
+
+  if (!name || typeof name !== 'string') {
+    return new Response(JSON.stringify({ error: 'Name is required' }), {
+      status: 400,
+      headers: { 'Content-Type': 'application/json' }
+    });
+  }
+
+  try {
+    const magicalGirl = await generateMagicalGirlWithAI(name.trim());
+    return new Response(JSON.stringify(magicalGirl), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' }
+    });
+  } catch (error) {
+    console.error('生成魔法少女失败:', error);
+    return new Response(JSON.stringify({ error: '生成失败,请稍后重试' }), {
+      status: 500,
+      headers: { 'Content-Type': 'application/json' }
+    });
+  }
+}
+
+export default handler;

+ 99 - 0
pages/arrested.tsx

@@ -0,0 +1,99 @@
+import React from 'react';
+import Head from 'next/head';
+
+export default function ArrestedPage() {
+    return (
+        <>
+            <Head>
+                <title>调查院正在出动 - 魔法国度调查院</title>
+                <meta name="description" content="魔法国度调查院逮捕令" />
+                <link rel="icon" href="/favicon.svg" />
+            </Head>
+
+            <div className="min-h-screen bg-gradient-to-br from-purple-900 via-violet-800 to-indigo-900 text-white font-sans relative overflow-hidden">
+                {/* Magical background patterns */}
+                <div className="absolute inset-0 opacity-10">
+                    <div className="absolute top-10 left-10 text-6xl animate-pulse">🌸</div>
+                    <div className="absolute top-20 right-20 text-4xl animate-bounce">🌿</div>
+                    <div className="absolute top-1/3 left-1/4 text-5xl animate-pulse">🌺</div>
+                    <div className="absolute top-2/3 right-1/3 text-3xl animate-bounce">🍃</div>
+                    <div className="absolute bottom-20 left-20 text-4xl animate-pulse">🌹</div>
+                    <div className="absolute bottom-10 right-10 text-5xl animate-bounce">🌷</div>
+                    <div className="absolute top-1/2 left-10 text-3xl animate-pulse">🌻</div>
+                    <div className="absolute top-1/4 right-1/4 text-4xl animate-bounce">🌼</div>
+                </div>
+
+                {/* Floating magical particles */}
+                <div className="absolute inset-0 overflow-hidden pointer-events-none">
+                    <div className="absolute animate-float w-2 h-2 bg-purple-300 rounded-full opacity-60" style={{ top: '20%', left: '15%', animationDelay: '0s' }}></div>
+                    <div className="absolute animate-float w-1 h-1 bg-violet-300 rounded-full opacity-70" style={{ top: '40%', left: '80%', animationDelay: '1s' }}></div>
+                    <div className="absolute animate-float w-3 h-3 bg-pink-300 rounded-full opacity-50" style={{ top: '60%', left: '25%', animationDelay: '2s' }}></div>
+                    <div className="absolute animate-float w-1 h-1 bg-purple-200 rounded-full opacity-80" style={{ top: '80%', left: '70%', animationDelay: '3s' }}></div>
+                </div>
+
+
+                {/* Main magical content */}
+                <div className="container mx-auto px-4 py-8 relative z-10" style={{ marginTop: '5rem' }}>
+                    {/* Enchanted warning banner */}
+                    <div className="text-center text-purple-100" style={{ marginBottom: '0.5rem' }}>您的结果是</div>
+                    <div className="bg-gradient-to-r from-pink-600 via-purple-600 to-violet-600 border-2 border-pink-400 rounded-lg p-6 mb-8 text-center shadow-2xl relative overflow-hidden">
+                        <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-10 animate-pulse"></div>
+                        <div className="relative z-10">
+                            <div className="text-4xl font-semibold text-purple-100" style={{ padding: '2rem' }}>
+                                批 准 逮 捕
+                            </div>
+                        </div>
+                    </div>
+
+                    {/* Mystical arrest warrant */}
+                    <div className="bg-gradient-to-b from-purple-900 to-violet-900 border-2 border-pink-500 rounded-lg p-8 mb-8 shadow-2xl relative">
+                        {/* SVG background pattern */}
+                        <div
+                            className="absolute inset-0 rounded-lg opacity-20 bg-no-repeat bg-center bg-contain"
+                            style={{
+                                backgroundImage: 'url(/arrest-frame.svg)',
+                                backgroundSize: 'contain',
+                                backgroundPosition: 'center',
+                            }}
+                        ></div>
+                        <div className="absolute top-4 right-4 text-2xl animate-spin">🌟</div>
+                        <div className="absolute bottom-4 left-4 text-2xl animate-bounce">🌟</div>
+
+                        <div className="text-center mb-8" style={{ padding: '2rem' }}>
+                            <div className="text-lg md:text-xl text-purple-200 font-semibold" style={{ padding: '2rem', marginBottom: '4rem' }}>
+                                <p>调查使正在前往您的所在地</p>
+                                <p>请勿离开该界面</p>
+                            </div>
+                            <div className="text-purple-100 space-y-3" style={{ marginBottom: '8rem' }}>
+                                <p className="flex items-center justify-center gap-2" style={{ marginBottom: '0.5rem' }}>
+                                    ⚠️ 金绿猫眼权杖严正声明 ⚠️
+                                </p>
+                                <p className="text-xl flex items-center justify-center gap-2">
+                                    城际网络并非法外之地
+                                </p>
+                            </div>
+                        </div>
+                    </div>
+
+                    {/* Magical footer warning */}
+                    <div className="mt-8 text-center">
+                        <div className="text-purple-300 text-sm" style={{ marginTop: '1rem' }}>
+                            本逮捕令由魔法国度调查院授权发布
+                        </div>
+                    </div>
+                </div>
+
+                {/* Custom animations */}
+                <style jsx>{`
+          @keyframes float {
+            0%, 100% { transform: translateY(0px) rotate(0deg); }
+            50% { transform: translateY(-20px) rotate(180deg); }
+          }
+          .animate-float {
+            animation: float 4s ease-in-out infinite;
+          }
+        `}</style>
+            </div>
+        </>
+    );
+}

Разлика између датотеке није приказан због своје велике величине
+ 571 - 0
pages/details.tsx


+ 428 - 0
pages/index.tsx

@@ -0,0 +1,428 @@
+import React, { useState, useRef } from 'react';
+import Head from 'next/head';
+import { snapdom } from '@zumer/snapdom';
+// TODO: 从这里引入怪怪的,但是先这样吧!
+import { type AIGeneratedMagicalGirl } from './api/generate-magical-girl';
+import { MainColor } from '../lib/main-color';
+import Link from 'next/link';
+import { useCooldown } from '../lib/cooldown';
+import { quickCheck } from '@/lib/sensitive-word-filter';
+import { useRouter } from 'next/router';
+
+interface MagicalGirl {
+  realName: string;
+  name: string;
+  flowerDescription: string;
+  appearance: {
+    height: string;
+    weight: string;
+    hairColor: string;
+    hairStyle: string;
+    eyeColor: string;
+    skinTone: string;
+    wearing: string;
+    specialFeature: string;
+    mainColor: string; // 写法有点诡异,但是能用就行.jpg
+    firstPageColor: string;
+    secondPageColor: string;
+  };
+  spell: string;
+  level: string;
+  levelEmoji: string;
+}
+
+// 保留原有的 levels 数组和相关函数
+const levels = [
+  { name: '种', emoji: '🌱' },
+  { name: '芽', emoji: '🍃' },
+  { name: '叶', emoji: '🌿' },
+  { name: '蕾', emoji: '🌸' },
+  { name: '花', emoji: '🌺' },
+  { name: '宝石权杖', emoji: '💎' }
+];
+
+// 定义8套渐变配色方案,与 MainColor 枚举顺序对应
+const gradientColors: Record<string, { first: string; second: string }> = {
+  [MainColor.Red]: { first: '#ff6b6b', second: '#ee5a6f' },
+  [MainColor.Orange]: { first: '#ff922b', second: '#ffa94d' },
+  [MainColor.Cyan]: { first: '#22b8cf', second: '#66d9e8' },
+  [MainColor.Blue]: { first: '#5c7cfa', second: '#748ffc' },
+  [MainColor.Purple]: { first: '#9775fa', second: '#b197fc' },
+  [MainColor.Pink]: { first: '#ff9a9e', second: '#fecfef' },
+  [MainColor.Yellow]: { first: '#f59f00', second: '#fcc419' },
+  [MainColor.Green]: { first: '#51cf66', second: '#8ce99a' }
+};
+
+function seedRandom(str: string): number {
+  let hash = 0;
+  for (let i = 0; i < str.length; i++) {
+    const char = str.charCodeAt(i);
+    hash = ((hash << 5) - hash) + char;
+    hash = hash & hash;
+  }
+  return Math.abs(hash);
+}
+
+function getWeightedRandomFromSeed<T>(array: T[], weights: number[], seed: number, offset: number = 0): T {
+  // 使用种子生成 0-1 之间的伪随机数
+  const pseudoRandom = ((seed + offset) * 9301 + 49297) % 233280 / 233280.0;
+
+  // 累计权重
+  let cumulativeWeight = 0;
+  const cumulativeWeights = weights.map(weight => cumulativeWeight += weight);
+  const totalWeight = cumulativeWeights[cumulativeWeights.length - 1];
+
+  // 找到对应的索引
+  const randomValue = pseudoRandom * totalWeight;
+  const index = cumulativeWeights.findIndex(weight => randomValue <= weight);
+
+  return array[index >= 0 ? index : 0];
+}
+
+function checkNameLength(name: string): boolean {
+  return name.length <= 300;
+}
+
+// 使用 API 路由生成魔法少女
+async function generateMagicalGirl(inputName: string): Promise<MagicalGirl> {
+  try {
+    const response = await fetch('/api/generate-magical-girl', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({ name: inputName }),
+    });
+
+    if (!response.ok) {
+      const error = await response.json();
+
+      // 处理不同的 HTTP 状态码
+      if (response.status === 429) {
+        const retryAfter = error.retryAfter || 60;
+        throw new Error(`请求过于频繁!请等待 ${retryAfter} 秒后再试。`);
+      } else if (response.status >= 500) {
+        throw new Error('服务器内部错误,请稍后重试');
+      } else {
+        throw new Error(error.message || error.error || '生成失败');
+      }
+    }
+
+    const aiGenerated: AIGeneratedMagicalGirl = await response.json();
+
+    // 等级概率配置: [种, 芽, 叶, 蕾, 花, 宝石权杖]
+    const levelProbabilities = [0.1, 0.2, 0.3, 0.3, 0.07, 0.03];
+
+    // 使用加权随机选择生成 level
+    const seed = seedRandom(aiGenerated.flowerName + inputName);
+    const level = getWeightedRandomFromSeed(levels, levelProbabilities, seed, 6);
+
+    return {
+      realName: inputName,
+      name: aiGenerated.flowerName,
+      flowerDescription: aiGenerated.flowerDescription,
+      appearance: aiGenerated.appearance,
+      spell: aiGenerated.spell,
+      level: level.name,
+      levelEmoji: level.emoji
+    };
+  } catch (error) {
+    // 处理网络错误和其他异常
+    if (error instanceof Error) {
+      // 如果已经是我们抛出的错误,直接重新抛出
+      if (error.message.includes('请求过于频繁') ||
+        error.message.includes('服务器内部错误') ||
+        error.message.includes('生成失败')) {
+        throw error;
+      }
+    }
+
+    // 处理网络连接错误
+    if (error instanceof TypeError && error.message.includes('fetch')) {
+      throw new Error('网络连接失败,请检查网络后重试');
+    }
+
+    // 其他未知错误
+    throw new Error('生成魔法少女时发生未知错误,请重试');
+  }
+}
+
+export default function Home() {
+  const [inputName, setInputName] = useState('');
+  const [magicalGirl, setMagicalGirl] = useState<MagicalGirl | null>(null);
+  const [isGenerating, setIsGenerating] = useState(false);
+  const [showImageModal, setShowImageModal] = useState(false);
+  const [savedImageUrl, setSavedImageUrl] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const resultRef = useRef<HTMLDivElement>(null);
+  const { isCooldown, startCooldown, remainingTime } = useCooldown('generateMagicalGirlCooldown', 60000);
+  const router = useRouter();
+
+  const handleGenerate = async () => {
+    if (isCooldown) {
+      setError(`请等待 ${remainingTime} 秒后再生成`);
+      return;
+    }
+    if (!inputName.trim()) return;
+
+    if (!checkNameLength(inputName)) {
+      setError('名字太长啦,你怎么回事!');
+      return;
+    }
+    // 检查敏感词
+    const result = await quickCheck(inputName.trim());
+    if (result.hasSensitiveWords) {
+      router.push('/arrested');
+      return;
+    }
+    setIsGenerating(true);
+    setError(null); // 清除之前的错误
+
+    try {
+      const result = await generateMagicalGirl(inputName.trim());
+      setMagicalGirl(result);
+      setError(null); // 成功时清除错误
+    } catch (error) {
+      // 处理不同类型的错误
+      if (error instanceof Error) {
+        const errorMessage = error.message;
+
+        // 检查是否是 rate limit 错误
+        if (errorMessage.includes('请求过于频繁')) {
+          setError('🚫 请求太频繁了!每2分钟只能生成一次魔法少女哦~请稍后再试吧!');
+        } else if (errorMessage.includes('网络')) {
+          setError('🌐 网络连接有问题!请检查网络后重试~');
+        } else {
+          setError(`✨ 魔法失效了!${errorMessage}`);
+        }
+      } else {
+        setError('✨ 魔法失效了!可能是用的人太多狸!请再生成一次试试吧~');
+      }
+    } finally {
+      setIsGenerating(false);
+      startCooldown();
+    }
+  };
+
+  const handleSaveImage = async () => {
+    if (!resultRef.current) return;
+
+    try {
+      // 临时隐藏保存按钮和说明文字
+      const saveButton = resultRef.current.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'none';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'flex';
+
+      const result = await snapdom(resultRef.current, {
+        scale: 1,
+      });
+
+      // 恢复按钮显示
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+
+      // 获取 result.toPng() 生成的 HTMLImageElement 的图片 URL
+      // toPng() 返回 Promise<HTMLImageElement>,可通过 .src 获取图片的 base64 url
+      const imgElement = await result.toPng();
+      const imageUrl = imgElement.src;
+      setSavedImageUrl(imageUrl);
+      setShowImageModal(true);
+    } catch {
+      alert('生成图片失败,请重试');
+      // 确保在失败时也恢复按钮显示
+      const saveButton = resultRef.current?.querySelector('.save-button') as HTMLElement;
+      const logoPlaceholder = resultRef.current?.querySelector('.logo-placeholder') as HTMLElement;
+
+      if (saveButton) saveButton.style.display = 'block';
+      if (logoPlaceholder) logoPlaceholder.style.display = 'none';
+    }
+  };
+
+  return (
+    <>
+      <Head>
+        <link rel="preload" href="/logo.svg" as="image" type="image/svg+xml" />
+        <link rel="preload" href="/logo-white.svg" as="image" type="image/svg+xml" />
+      </Head>
+      <div className="magic-background">
+        <div className="container">
+          <div className="card">
+            <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '1rem' }}>
+              <img src="/logo.svg" width={250} height={160} alt="Logo" />
+            </div>
+            <p className="subtitle text-center">你是什么魔法少女呢!</p>
+            <p className="subtitle text-center">
+              或者要不要来试试 <Link href="/details" className="footer-link">奇妙妖精大调查</Link>或 <Link href="/acg" className="footer-link">二次元魔法少女生成器</Link>?
+            </p>
+            <div style={{ marginTop: '1rem', marginBottom: '2rem', textAlign: 'center' }}>
+              <p style={{ fontSize: '0.8rem', marginTop: '1rem', color: '#999', fontStyle: 'italic' }}>本测试设定来源于小说《下班,然后变成魔法少女》</p>
+              <p style={{ fontSize: '0.8rem', marginTop: '0.2rem', color: '#999', fontStyle: '' }}><del>以及广告位募集中</del></p>
+              <p style={{ fontSize: '0.8rem', marginTop: '0.2rem', color: '#999', fontStyle: '' }}><del>如有意向请联系魔法国度研究院院长 @祖母绿:1********</del></p>
+            </div>
+            <div className="input-group">
+              <label htmlFor="name" className="input-label">
+                请输入你的名字:
+              </label>
+              <input
+                id="name"
+                type="text"
+                value={inputName}
+                onChange={(e) => setInputName(e.target.value)}
+                className="input-field"
+                placeholder="例如:鹿目圆香"
+                onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
+              />
+            </div>
+
+            <button
+              onClick={handleGenerate}
+              disabled={!inputName.trim() || isGenerating || isCooldown}
+              className="generate-button"
+            >
+              {isCooldown
+                ? `请等待 ${remainingTime} 秒`
+                : isGenerating
+                  ? '少女创造中,请稍后捏 (≖ᴗ≖)✧✨'
+                  : 'へんしん(ノ゚▽゚)ノ! '}
+            </button>
+
+            {error && (
+              <div className="error-message">
+                {error}
+              </div>
+            )}
+
+            {magicalGirl && (
+              <div
+                ref={resultRef}
+                className="result-card"
+                style={{
+                  background: (() => {
+                    const colors = gradientColors[magicalGirl.appearance.mainColor] || gradientColors[MainColor.Pink];
+                    return `linear-gradient(135deg, ${colors.first} 0%, ${colors.second} 100%)`;
+                  })()
+                }}
+              >
+                <div className="result-content">
+                  <div className="flex justify-center items-center" style={{ marginBottom: '1rem', background: 'transparent' }}>
+                    <img src="/mahou-title.svg" width={300} height={70} alt="Logo" style={{ display: 'block', background: 'transparent' }} />
+                  </div>
+                  <div className="result-item">
+                    <div className="result-label">✨ 真名解放</div>
+                    <div className="result-value">{magicalGirl.realName}</div>
+                  </div>
+                  <div className="result-item">
+                    <div className="result-label">💝 魔法少女名</div>
+                    <div className="result-value">
+                      {magicalGirl.name}
+                      <div style={{ fontStyle: 'italic', marginTop: '8px', fontSize: '14px', opacity: 0.9 }}>
+                        「{magicalGirl.flowerDescription}」
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="result-item">
+                    <div className="result-label">👗 外貌</div>
+                    <div className="result-value">
+                      身高:{magicalGirl.appearance.height}<br />
+                      体重:{magicalGirl.appearance.weight}<br />
+                      发色:{magicalGirl.appearance.hairColor}<br />
+                      发型:{magicalGirl.appearance.hairStyle}<br />
+                      瞳色:{magicalGirl.appearance.eyeColor}<br />
+                      肤色:{magicalGirl.appearance.skinTone}<br />
+                      穿着:{magicalGirl.appearance.wearing}<br />
+                      特征:{magicalGirl.appearance.specialFeature}
+                    </div>
+                  </div>
+
+                  <div className="result-item">
+                    <div className="result-label">✨ 变身咒语</div>
+                    <div className="result-value">
+                      <div style={{ whiteSpace: 'pre-line' }}>{magicalGirl.spell}</div>
+                    </div>
+                  </div>
+
+                  <div className="result-item">
+                    <div className="result-label">⭐ 魔法等级</div>
+                    <div className="result-value">
+                      <span className="level-badge">
+                        {magicalGirl.levelEmoji} {magicalGirl.level}
+                      </span>
+                    </div>
+                  </div>
+
+                  <button onClick={handleSaveImage} className="save-button">
+                    📱 保存为图片
+                  </button>
+
+                  {/* Logo placeholder for saved images */}
+                  <div className="logo-placeholder" style={{ display: 'none', justifyContent: 'center', marginTop: '1rem' }}>
+                    <img
+                      src="/logo-white-qrcode.svg"
+                      width={320}
+                      height={80}
+                      alt="Logo"
+                      style={{
+                        display: 'block',
+                        maxWidth: '100%',
+                        height: 'auto'
+                      }}
+                    />
+                  </div>
+                </div>
+              </div>
+            )}
+            <div className="text-center w-full text-sm text-gray-500" style={{ marginTop: '8px' }}> 立绘生成功能开发中(大概)... </div>
+          </div>
+
+          <footer className="footer">
+            <p>
+              <a href="https://github.com/colasama" target="_blank" rel="noopener noreferrer" className="footer-link">@Colanns</a> 急速出品
+            </p>
+            <p>
+              本项目 AI 能力由&nbsp;
+              <a href="https://github.com/KouriChat/KouriChat" target="_blank" rel="noopener noreferrer" className="footer-link">KouriChat</a> &&nbsp;
+              <a href="https://api.kourichat.com/" target="_blank" rel="noopener noreferrer" className="footer-link">Kouri API</a>
+              &nbsp;强力支持
+            </p>
+            <p>
+              <a href="https://github.com/colasama/MahoShojo-Generator" target="_blank" rel="noopener noreferrer" className="footer-link">colasama/MahoShojo-Generator</a>
+            </p>
+          </footer>
+        </div>
+
+        {/* Image Modal */}
+        {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>
+    </>
+  );
+} 

+ 5 - 0
postcss.config.mjs

@@ -0,0 +1,5 @@
+const config = {
+  plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;

+ 2 - 0
public/_headers

@@ -0,0 +1,2 @@
+/api/*
+  X-Robots-Tag: noindex

+ 19 - 0
public/acg_questionnaire.json

@@ -0,0 +1,19 @@
+{
+    "questions": [
+        "你的常用昵称是?",
+        "在梦中,你最常梦见的场景是什么?",
+        "如果能拥有一种超能力,你希望是?",
+        "你最珍视的宝物是什么?",
+        "当你遇到无法独自解决的困难时,你会向谁求助?",
+        "你认为什么样的羁绊是最强大的?",
+        "你最喜欢的动漫名言是哪一句?",
+        "如果你的朋友被坏人抓走了,你会怎么做?",
+        "你希望你的魔法武器是什么样的?",
+        "你最喜欢的二次元萌属性是什么?",
+        "你认为“爱”与“正义”哪个更重要?",
+        "如果可以转生到异世界,你希望成为什么样的角色?",
+        "你最想守护的东西是什么?",
+        "你相信奇迹吗?",
+        "请用一个词来形容你心中的“魔法少女”。"
+    ]
+}

Разлика између датотеке није приказан због своје велике величине
+ 23 - 0
public/arrest-frame.svg


Разлика између датотеке није приказан због своје велике величине
+ 24 - 0
public/favicon.svg


+ 1 - 0
public/file.svg

@@ -0,0 +1 @@
+<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
public/globe.svg


Разлика између датотеке није приказан због своје велике величине
+ 137 - 0
public/logo-white-qrcode.svg


Разлика између датотеке није приказан због своје велике величине
+ 91 - 0
public/logo-white.svg


Разлика између датотеке није приказан због своје велике величине
+ 85 - 0
public/logo.svg


Разлика између датотеке није приказан због своје велике величине
+ 50 - 0
public/mahou-title.svg


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
public/next.svg


Разлика између датотеке није приказан због своје велике величине
+ 81 - 0
public/questionnaire-logo.svg


Разлика између датотеке није приказан због своје велике величине
+ 144 - 0
public/questionnaire-title.svg


+ 20 - 0
public/questionnaire.json

@@ -0,0 +1,20 @@
+{
+    "questions": [
+        "你的真实名字是?",
+        "假如前辈事先告诉你无论如何都不要插手她的战斗,而她现在在你眼前即将被敌人杀死,你会怎么做?",
+        "你与搭档一起执行任务时,她的失误导致你身受重伤,而她也为此而自责,你会怎么做?",
+        "你是否愿意遭受会使你永久失去大部分力量的重大伤势,以拯救临时和你一起行动的不熟悉的同伴?",
+        "你第一次使用魔法时,最希望完成的事情是?",
+        "你更希望获得什么样的能力?",
+        "请写下一个你现在脑中浮现的名词(如灯火、盾牌、星辰等)。",
+        "对你而言,是\"挫败敌人\"更重要,还是\"保护队友\"更重要?",
+        "你认为命运是注定的,还是一切都能改变?",
+        "如果必须牺牲无辜的少数才能拯救多数,你会如何选择?",
+        "你会如何看待\"必要之恶\"?",
+        "假如你发现你的前辈或上级做出了错误的决策,并且没有人指出来,你会怎么做?",
+        "你更喜欢独自行动,还是和伙伴一起?",
+        "你在执行任务时更倾向计划周密还是依赖直觉?",
+        "你人生中最难忘的一个瞬间是什么?",
+        "有没有一个你至今仍然后悔的决定?你现在会怎么做?"
+    ]
+}

+ 0 - 0
public/tamamani.json


+ 1 - 0
public/vercel.svg

@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

+ 1 - 0
public/window.svg

@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

+ 293 - 0
styles/blue-theme.css

@@ -0,0 +1,293 @@
+@import "tailwindcss";
+
+* {
+    box-sizing: border-box;
+    padding: 0;
+    margin: 0;
+}
+
+html,
+body {
+    max-width: 100vw;
+    overflow-x: hidden;
+    font-family: 'Arial', sans-serif;
+}
+
+body {
+    background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
+    min-height: 100vh;
+}
+
+a {
+    color: inherit;
+    text-decoration: none;
+}
+
+@media (prefers-color-scheme: dark) {
+    html {
+        color-scheme: dark;
+    }
+}
+
+
+/* 蓝色主题样式 - 只在 .blue-theme 容器内生效 */
+
+.blue-theme .magic-background {
+    background: linear-gradient(45deg, #4f46e5 0%, #7c3aed 50%, #3b82f6 100%);
+    min-height: 100vh;
+    position: relative;
+    overflow: hidden;
+}
+
+.magic-background::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-image: radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.2) 2px, transparent 2px), radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.15) 1px, transparent 1px), radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
+    background-size: 50px 50px, 30px 30px, 40px 40px;
+    animation: sparkle 6s ease-in-out infinite;
+}
+
+@keyframes sparkle {
+    0%,
+    100% {
+        opacity: 1;
+    }
+    50% {
+        opacity: 0.7;
+    }
+}
+
+.container {
+    max-width: 500px;
+    margin: 0 auto;
+    padding: 20px;
+    position: relative;
+    z-index: 1;
+}
+
+.card {
+    background: rgba(255, 255, 255, 0.95);
+    border-radius: 20px;
+    padding: 30px;
+    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
+    margin-top: 100px;
+    backdrop-filter: blur(10px);
+    border: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.blue-theme .title {
+    text-align: center;
+    font-size: 2rem;
+    font-weight: bold;
+    background: linear-gradient(45deg, #3b82f6, #1d4ed8, #1e40af);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+    background-clip: text;
+    margin-bottom: 30px;
+}
+
+.input-group {
+    margin-bottom: 20px;
+}
+
+.input-label {
+    display: block;
+    margin-bottom: 8px;
+    font-weight: 600;
+    color: #555;
+}
+
+.input-field {
+    width: 100%;
+    padding: 15px;
+    border: 2px solid #e0e0e0;
+    color: #333;
+    border-radius: 12px;
+    font-size: 16px;
+    transition: all 0.3s ease;
+    background: rgba(255, 255, 255, 0.8);
+}
+
+.blue-theme .input-field:focus {
+    outline: none;
+    border-color: #3b82f6;
+    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.blue-theme .generate-button {
+    width: 100%;
+    padding: 15px;
+    background: linear-gradient(45deg, #3b82f6, #1d4ed8);
+    color: white;
+    border: none;
+    border-radius: 12px;
+    font-size: 18px;
+    font-weight: bold;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    margin-bottom: 20px;
+}
+
+.blue-theme .generate-button:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 10px 20px rgba(59, 130, 246, 0.3);
+}
+
+.generate-button:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+    transform: none;
+}
+
+.blue-theme .result-card {
+    background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
+    color: white;
+    border-radius: 15px;
+    padding: 25px;
+    margin-top: 20px;
+    position: relative;
+    overflow: hidden;
+}
+
+.result-card::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="25" cy="25" r="2" fill="rgba(255,255,255,0.1)"/><circle cx="75" cy="75" r="1.5" fill="rgba(255,255,255,0.1)"/><circle cx="80" cy="20" r="1" fill="rgba(255,255,255,0.1)"/></svg>');
+    background-size: 100px 100px;
+}
+
+.title-container {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-bottom: 1rem;
+    background: transparent;
+}
+
+.result-content {
+    position: relative;
+    z-index: 1;
+}
+
+.result-item {
+    margin-bottom: 20px;
+    padding: 15px;
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 10px;
+    backdrop-filter: blur(5px);
+}
+
+.result-label {
+    font-weight: bold;
+    font-size: 14px;
+    opacity: 0.9;
+    margin-bottom: 5px;
+}
+
+.result-value {
+    font-size: 16px;
+    line-height: 1.5;
+}
+
+.level-badge {
+    display: inline-block;
+    padding: 8px 16px;
+    background: rgba(255, 255, 255, 0.2);
+    border-radius: 20px;
+    font-weight: bold;
+    font-size: 18px;
+}
+
+.save-button {
+    width: 100%;
+    padding: 12px;
+    background: rgba(255, 255, 255, 0.2);
+    color: white;
+    border: 2px solid rgba(255, 255, 255, 0.3);
+    border-radius: 10px;
+    font-size: 16px;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    margin-top: 15px;
+}
+
+.save-button:hover {
+    background: rgba(255, 255, 255, 0.3);
+}
+
+.save-instructions {
+    text-align: center;
+    margin-top: 10px;
+    font-size: 12px;
+    opacity: 0.8;
+}
+
+@media (max-width: 600px) {
+    .container {
+        padding: 15px;
+    }
+    .card {
+        padding: 20px;
+    }
+    .blue-theme .title {
+        font-size: 1.5rem;
+    }
+}
+
+.subtitle {
+    text-align: center;
+    font-size: 1rem;
+    color: #666;
+    margin-bottom: 25px;
+    font-style: italic;
+}
+
+.blue-theme .error-message {
+    background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+    color: white;
+    padding: 15px;
+    border-radius: 10px;
+    margin: 15px 0;
+    text-align: center;
+    font-weight: 500;
+    animation: fadeIn 0.3s ease-in-out;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(-10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.footer {
+    text-align: center;
+    padding: 20px 0;
+    margin-top: 30px;
+    color: #666;
+    font-size: 14px;
+}
+
+.blue-theme .footer-link {
+    color: #abcbff;
+    font-weight: 600;
+    text-decoration: none;
+    transition: all 0.3s ease;
+}
+
+.blue-theme .footer-link:hover {
+    color: #8298d6;
+    text-decoration: underline;
+}

+ 305 - 0
styles/globals.css

@@ -0,0 +1,305 @@
+@import "tailwindcss";
+
+* {
+    box-sizing: border-box;
+    padding: 0;
+    margin: 0;
+}
+
+html,
+body {
+    max-width: 100vw;
+    overflow-x: hidden;
+    font-family: 'Arial', sans-serif;
+}
+
+body {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    min-height: 100vh;
+}
+
+a {
+    color: inherit;
+    text-decoration: none;
+}
+
+@media (prefers-color-scheme: dark) {
+    html {
+        color-scheme: dark;
+    }
+}
+
+
+/* 魔法少女主题样式 */
+
+.magic-background {
+    background: linear-gradient(45deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%);
+    min-height: 100vh;
+    position: relative;
+    overflow: hidden;
+}
+
+.magic-background::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-image: radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.2) 2px, transparent 2px), radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.15) 1px, transparent 1px), radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
+    background-size: 50px 50px, 30px 30px, 40px 40px;
+    animation: sparkle 6s ease-in-out infinite;
+}
+
+@keyframes sparkle {
+    0%,
+    100% {
+        opacity: 1;
+    }
+    50% {
+        opacity: 0.7;
+    }
+}
+
+.container {
+    max-width: 500px;
+    margin: 0 auto;
+    padding: 20px;
+    position: relative;
+    z-index: 1;
+}
+
+.card {
+    background: rgba(255, 255, 255, 0.95);
+    border-radius: 20px;
+    padding: 30px;
+    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
+    margin-top: 100px;
+    backdrop-filter: blur(10px);
+    border: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.title {
+    text-align: center;
+    font-size: 2rem;
+    font-weight: bold;
+    background: linear-gradient(45deg, #ff6b6b, #ee5a6f, #d63384);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+    background-clip: text;
+    margin-bottom: 30px;
+}
+
+.input-group {
+    margin-bottom: 20px;
+}
+
+.input-label {
+    display: block;
+    margin-bottom: 8px;
+    font-weight: 600;
+    color: #555;
+}
+
+.input-field {
+    width: 100%;
+    padding: 15px;
+    border: 2px solid #e0e0e0;
+    color: #333;
+    border-radius: 12px;
+    font-size: 16px;
+    transition: all 0.3s ease;
+    background: rgba(255, 255, 255, 0.8);
+}
+
+.input-field:focus {
+    outline: none;
+    border-color: #ff6b6b;
+    box-shadow: 0 0 0 3px rgba(255, 107, 107, 0.1);
+}
+
+.generate-button {
+    width: 100%;
+    padding: 15px;
+    background: linear-gradient(45deg, #ff6b6b, #ee5a6f);
+    color: white;
+    border: none;
+    border-radius: 12px;
+    font-size: 18px;
+    font-weight: bold;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    margin-bottom: 20px;
+}
+
+.generate-button:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 10px 20px rgba(255, 107, 107, 0.3);
+}
+
+.generate-button:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+    transform: none;
+}
+
+.result-card {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    border-radius: 15px;
+    padding: 25px;
+    margin-top: 20px;
+    position: relative;
+    overflow: hidden;
+}
+
+.result-card::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="25" cy="25" r="2" fill="rgba(255,255,255,0.1)"/><circle cx="75" cy="75" r="1.5" fill="rgba(255,255,255,0.1)"/><circle cx="80" cy="20" r="1" fill="rgba(255,255,255,0.1)"/></svg>');
+    background-size: 100px 100px;
+}
+
+.title-container {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-bottom: 1rem;
+    background: transparent;
+}
+
+.result-content {
+    position: relative;
+    z-index: 1;
+}
+
+.result-item {
+    margin-bottom: 20px;
+    padding: 15px;
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 10px;
+    backdrop-filter: blur(5px);
+}
+
+.result-label {
+    font-weight: bold;
+    font-size: 14px;
+    opacity: 0.9;
+    margin-bottom: 5px;
+}
+
+.result-value {
+    font-size: 16px;
+    line-height: 1.5;
+}
+
+.level-badge {
+    display: inline-block;
+    padding: 8px 16px;
+    background: rgba(255, 255, 255, 0.2);
+    border-radius: 20px;
+    font-weight: bold;
+    font-size: 18px;
+}
+
+.save-button {
+    width: 100%;
+    padding: 12px;
+    background: rgba(255, 255, 255, 0.2);
+    color: white;
+    border: 2px solid rgba(255, 255, 255, 0.3);
+    border-radius: 10px;
+    font-size: 16px;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    margin-top: 15px;
+}
+
+.save-button:hover {
+    background: rgba(255, 255, 255, 0.3);
+}
+
+.save-instructions {
+    text-align: center;
+    margin-top: 10px;
+    font-size: 12px;
+    opacity: 0.8;
+}
+
+@media (max-width: 600px) {
+    .container {
+        padding: 15px;
+    }
+    .card {
+        padding: 20px;
+    }
+    .title {
+        font-size: 1.5rem;
+    }
+}
+
+.subtitle {
+    text-align: center;
+    font-size: 1rem;
+    color: #666;
+    margin-bottom: 25px;
+    font-style: italic;
+}
+
+.error-message {
+    background: linear-gradient(135deg, #ff6b6b, #ee5a6f);
+    color: white;
+    padding: 15px;
+    border-radius: 10px;
+    margin: 15px 0;
+    text-align: center;
+    font-weight: 500;
+    animation: fadeIn 0.3s ease-in-out;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(-10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.footer {
+    text-align: center;
+    padding: 20px 0;
+    margin-top: 30px;
+    color: #666;
+    font-size: 14px;
+}
+
+.footer-link {
+    color: #ff6b6b;
+    font-weight: 600;
+    text-decoration: none;
+    transition: all 0.3s ease;
+}
+
+.footer-link:hover {
+    color: #ee5a6f;
+    text-decoration: underline;
+}
+
+/* 问题切换动画 */
+@keyframes slideInFromRight {
+    from {
+        opacity: 0;
+        transform: translateX(100px);
+    }
+    to {
+        opacity: 1;
+        transform: translateX(0);
+    }
+}

+ 185 - 0
tests/getWeightedRandomFromSeed.test.js

@@ -0,0 +1,185 @@
+// 分布概率验证测试
+// 测试 getWeightedRandomFromSeed 函数的分布概率
+
+// 复制核心函数实现
+function seedRandom(str) {
+  let hash = 0
+  for (let i = 0; i < str.length; i++) {
+    const char = str.charCodeAt(i)
+    hash = ((hash << 5) - hash) + char
+    hash = hash & hash
+  }
+  return Math.abs(hash)
+}
+
+function getWeightedRandomFromSeed(array, weights, seed, offset = 0) {
+  // 使用种子生成 0-1 之间的伪随机数
+  const pseudoRandom = ((seed + offset) * 9301 + 49297) % 233280 / 233280.0
+  
+  // 累计权重
+  let cumulativeWeight = 0
+  const cumulativeWeights = weights.map(weight => cumulativeWeight += weight)
+  const totalWeight = cumulativeWeights[cumulativeWeights.length - 1]
+  
+  // 找到对应的索引
+  const randomValue = pseudoRandom * totalWeight
+  const index = cumulativeWeights.findIndex(weight => randomValue <= weight)
+  
+  return array[index >= 0 ? index : 0]
+}
+
+// 测试配置
+const levels = [
+  { name: '种', emoji: '🌱' },
+  { name: '芽', emoji: '🍃' },
+  { name: '叶', emoji: '🌿' },
+  { name: '蕾', emoji: '🌸' },
+  { name: '花', emoji: '🌺' },
+  { name: '宝石权杖', emoji: '💎' }
+]
+
+const levelProbabilities = [0.1, 0.2, 0.3, 0.3, 0.07, 0.03]
+
+// 生成大量测试样本
+function runDistributionTest(sampleSize = 100000) {
+  console.log(`\n===== 分布概率验证测试 (样本数: ${sampleSize}) =====`)
+  console.log('预期概率:', levelProbabilities.map((p, i) => `${levels[i].name}: ${(p * 100).toFixed(1)}%`).join(', '))
+  
+  const results = {}
+  levels.forEach(level => results[level.name] = 0)
+  
+  // 生成测试样本
+  for (let i = 0; i < sampleSize; i++) {
+    // 使用不同的种子和偏移量来模拟真实使用场景
+    const testSeed = seedRandom(`test_${i}`)
+    const level = getWeightedRandomFromSeed(levels, levelProbabilities, testSeed, 6)
+    results[level.name]++
+  }
+  
+  // 计算实际概率
+  console.log('\n实际分布:')
+  levels.forEach((level, index) => {
+    const actualCount = results[level.name]
+    const actualProbability = actualCount / sampleSize
+    const expectedProbability = levelProbabilities[index]
+    const deviation = Math.abs(actualProbability - expectedProbability)
+    const deviationPercent = (deviation / expectedProbability * 100).toFixed(2)
+    
+    console.log(`${level.emoji} ${level.name}: ${actualCount}次 (${(actualProbability * 100).toFixed(2)}%) | 偏差: ${deviationPercent}%`)
+  })
+  
+  // 计算卡方检验
+  let chiSquare = 0
+  levels.forEach((level, index) => {
+    const observed = results[level.name]
+    const expected = sampleSize * levelProbabilities[index]
+    chiSquare += Math.pow(observed - expected, 2) / expected
+  })
+  
+  console.log(`\n卡方值: ${chiSquare.toFixed(4)}`)
+  console.log(`自由度: ${levels.length - 1}`)
+  
+  // 简单的分布质量评估
+  const maxExpectedDeviation = 0.02 // 2% 的最大预期偏差
+  let isDistributionGood = true
+  
+  levels.forEach((level, index) => {
+    const actualProbability = results[level.name] / sampleSize
+    const expectedProbability = levelProbabilities[index]
+    const deviation = Math.abs(actualProbability - expectedProbability)
+    
+    if (deviation > maxExpectedDeviation) {
+      isDistributionGood = false
+    }
+  })
+  
+  console.log(`\n分布质量评估: ${isDistributionGood ? '✅ 良好' : '⚠️ 需要关注'}`)
+  
+  return {
+    results,
+    chiSquare,
+    isDistributionGood
+  }
+}
+
+// 测试种子确定性
+function testSeedDeterminism() {
+  console.log('\n===== 种子确定性测试 =====')
+  
+  const testSeeds = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']
+  
+  testSeeds.forEach(name => {
+    const seed = seedRandom(name)
+    const results = []
+    
+    // 同一个种子应该产生相同的结果
+    for (let i = 0; i < 5; i++) {
+      const level = getWeightedRandomFromSeed(levels, levelProbabilities, seed, 6)
+      results.push(level.name)
+    }
+    
+    const allSame = results.every(result => result === results[0])
+    console.log(`${name} (种子: ${seed}): ${results[0]} ${allSame ? '✅' : '❌'}`)
+  })
+}
+
+// 测试边界情况
+function testEdgeCases() {
+  console.log('\n===== 边界情况测试 =====')
+  
+  // 测试极端权重
+  const extremeWeights = [0, 0, 0, 0, 0, 1] // 只有最后一个有权重
+  let result = getWeightedRandomFromSeed(levels, extremeWeights, 12345, 6)
+  console.log(`极端权重测试: ${result.name} (应该是宝石权杖)`)
+  
+  // 测试相等权重
+  const equalWeights = [1, 1, 1, 1, 1, 1]
+  const equalResults = {}
+  levels.forEach(level => equalResults[level.name] = 0)
+  
+  for (let i = 0; i < 10000; i++) {
+    result = getWeightedRandomFromSeed(levels, equalWeights, i, 6)
+    equalResults[result.name]++
+  }
+  
+  console.log('相等权重分布:')
+  levels.forEach(level => {
+    const count = equalResults[level.name]
+    const percentage = (count / 10000 * 100).toFixed(1)
+    console.log(`  ${level.name}: ${percentage}% (期望: ~16.7%)`)
+  })
+}
+
+// 运行所有测试
+function runAllTests() {
+  console.log('开始运行 getWeightedRandomFromSeed 分布概率验证测试...')
+  
+  // 小样本测试
+  runDistributionTest(10000)
+  
+  // 大样本测试
+  runDistributionTest(100000)
+  
+  // 种子确定性测试
+  testSeedDeterminism()
+  
+  // 边界情况测试
+  testEdgeCases()
+  
+  console.log('\n===== 测试完成 =====')
+}
+
+// 如果直接运行此文件
+if (typeof module !== 'undefined' && require.main === module) {
+  runAllTests()
+}
+
+// 导出函数供其他测试使用
+if (typeof module !== 'undefined') {
+  module.exports = {
+    runDistributionTest,
+    testSeedDeterminism,
+    testEdgeCases,
+    runAllTests
+  }
+}

+ 28 - 0
tsconfig.json

@@ -0,0 +1,28 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "lib": ["dom", "dom.iterable", "es6"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 20 - 0
types/html2canvas.d.ts

@@ -0,0 +1,20 @@
+// TODO: 无用,可删
+declare module 'html2canvas' {
+  interface Html2CanvasOptions {
+    backgroundColor?: string | null
+    scale?: number
+    useCORS?: boolean
+    allowTaint?: boolean
+    height?: number
+    width?: number
+    x?: number
+    y?: number
+  }
+
+  function html2canvas(
+    element: HTMLElement,
+    options?: Html2CanvasOptions
+  ): Promise<HTMLCanvasElement>
+
+  export default html2canvas
+} 

+ 13 - 0
wrangler.toml

@@ -0,0 +1,13 @@
+name = "ifmahoushoujo"
+compatibility_date = "2024-01-01"
+compatibility_flags = ["nodejs_compat"]
+
+[env.production]
+name = "ifmahoushoujo"
+compatibility_date = "2024-01-01"
+compatibility_flags = ["nodejs_compat"]
+
+[env.preview]
+name = "ifmahoushoujo-preview"
+compatibility_date = "2024-01-01" 
+compatibility_flags = ["nodejs_compat"]