acgti-anime-persona-quiz
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseACGTI Anime Persona Quiz
ACGTI动漫人格测试
Skill by ara.so — Daily 2026 Skills collection.
ACGTI (ACG Type Indicator) is a purely client-side Vue 3 + TypeScript quiz that maps 39 seven-point Likert-scale questions onto four MBTI dimensions (E/I, S/N, T/F, J/P), matches the result to one of 8 anime archetypes, and then selects a specific anime character from a 40+ entry database. No backend, no user data collection — everything runs in the browser.
来自ara.so的技能项目——Daily 2026技能合集。
ACGTI(ACG类型指示器)是一个纯客户端的Vue 3 + TypeScript测试应用,它将39道七级李克特量表题目对应到MBTI的四个维度(E/I、S/N、T/F、J/P),把测试结果匹配到8种动漫原型之一,再从包含40+条条目的数据库中选出对应的特定动漫角色。无需后端,不收集用户数据——所有操作均在浏览器中完成。
Installation & Local Development
安装与本地开发
bash
undefinedbash
undefinedClone the repo
克隆仓库
git clone https://github.com/tianxingleo/ACGTI.git
cd ACGTI
git clone https://github.com/tianxingleo/ACGTI.git
cd ACGTI
Install dependencies (Node 18+ recommended)
安装依赖(推荐Node 18+版本)
npm install
npm install
Start dev server (Vite, hot-reload)
启动开发服务器(Vite,支持热重载)
npm run dev
npm run dev
Type-check
类型检查
npx tsc --noEmit
npx tsc --noEmit
Production build → dist/
生产构建 → 输出到dist/目录
npm run build
npm run build
Preview production build locally
本地预览生产构建产物
npm run preview
The `dist/` folder uses `base: './'` (relative paths), so it deploys directly to any static host.
---npm run preview
`dist/`文件夹使用`base: './'`(相对路径),因此可以直接部署到任何静态托管平台。
---Project Architecture
项目架构
src/
├── components/ # Reusable UI (QuestionCard, ResultSummary, SharePoster …)
├── composables/
│ ├── useQuiz.ts # Quiz state machine & answer logic
│ └── useShare.ts # PNG poster export
├── data/ # ALL content lives here as JSON
│ ├── questions.json
│ ├── archetypes.json
│ ├── characters.json
│ ├── characterVisuals.json
│ └── characterProbabilities.json
├── pages/ # Vue route-level components
├── types/quiz.ts # Shared TypeScript types
├── utils/
│ ├── quizEngine.ts # Score → archetype → character pipeline
│ ├── characterVisuals.ts
│ ├── characterProbability.ts
│ └── storage.ts # localStorage helpers
└── router/index.tssrc/
├── components/ # 可复用UI组件(QuestionCard、ResultSummary、SharePoster等)
├── composables/
│ ├── useQuiz.ts # 测试状态机与答题逻辑
│ └── useShare.ts # PNG海报导出功能
├── data/ # 所有内容以JSON格式存储于此
│ ├── questions.json
│ ├── archetypes.json
│ ├── characters.json
│ ├── characterVisuals.json
│ └── characterProbabilities.json
├── pages/ # Vue路由级组件
├── types/quiz.ts # 共享TypeScript类型定义
├── utils/
│ ├── quizEngine.ts # 得分→原型→角色的处理流程
│ ├── characterVisuals.ts
│ ├── characterProbability.ts
│ └── storage.ts # localStorage工具函数
└── router/index.tsCore Types (src/types/quiz.ts
)
src/types/quiz.ts核心类型(src/types/quiz.ts
)
src/types/quiz.tsUnderstanding these types is essential before touching any data file or engine logic.
typescript
// MBTI dimension keys
export type Dimension = 'EI' | 'SN' | 'TF' | 'JP';
// One question entry
export interface Question {
id: number;
text: string;
dimension: Dimension;
archetypeWeights: Record<string, number>; // archetype id → weight (-3..+3)
tags?: string[];
}
// One of 8 archetypes
export interface Archetype {
id: string; // e.g. "glowing-protagonist"
name: string;
mbtiTypes: string[]; // e.g. ["ENFJ","ENFP"]
description: string;
strengths: string[];
weaknesses: string[];
color: string; // hex
}
// Anime character entry
export interface Character {
id: string; // unique slug, becomes the "character code"
name: string;
series: string;
mbtiType: string; // e.g. "ENFJ"
archetypeId: string;
tags: string[];
stats: { // 0–100 six-axis radar
energy: number;
intuition: number;
empathy: number;
logic: number;
order: number;
chaos: number;
};
}
// Visual theming per character
export interface CharacterVisual {
characterId: string;
portraitUrl: string;
backgroundUrl: string;
primaryColor: string;
accentColor: string;
}
// Final computed result passed to ResultPage
export interface QuizResult {
mbtiType: string; // e.g. "INFP"
dimensionScores: Record<Dimension, number>; // 50–100, direction-normalised
archetypeId: string;
characterId: string;
}在修改任何数据文件或引擎逻辑之前,理解这些类型定义至关重要。
typescript
// MBTI维度键名
export type Dimension = 'EI' | 'SN' | 'TF' | 'JP';
单道题目条目
export interface Question {
id: number;
text: string;
dimension: Dimension;
archetypeWeights: Record<string, number>; // 原型ID → 权重值(-3..+3)
tags?: string[];
}
// 8种原型之一
export interface Archetype {
id: string; // 示例:"glowing-protagonist"
name: string;
mbtiTypes: string[]; // 示例:["ENFJ","ENFP"]
description: string;
strengths: string[];
weaknesses: string[];
color: string; // 十六进制颜色值
}
// 动漫角色条目
export interface Character {
id: string; // 唯一短标识,作为“角色代码”
name: string;
series: string;
mbtiType: string; // 示例:"ENFJ"
archetypeId: string;
tags: string[];
stats: { // 0–100的六轴雷达图数据
energy: number;
intuition: number;
empathy: number;
logic: number;
order: number;
chaos: number;
};
}
// 角色专属视觉主题
export interface CharacterVisual {
characterId: string;
portraitUrl: string;
backgroundUrl: string;
primaryColor: string;
accentColor: string;
}
// 传递给ResultPage的最终计算结果
export interface QuizResult {
mbtiType: string; // 示例:"INFP"
dimensionScores: Record<Dimension, number>; // 50–100,方向归一化后的值
archetypeId: string;
characterId: string;
}Scoring Engine (src/utils/quizEngine.ts
)
src/utils/quizEngine.ts评分引擎(src/utils/quizEngine.ts
)
src/utils/quizEngine.tsThe engine is a pure function pipeline — ideal extension point.
typescript
import questions from '@/data/questions.json';
import archetypes from '@/data/archetypes.json';
import characters from '@/data/characters.json';
import type { Dimension, QuizResult } from '@/types/quiz';
type Answers = Record<number, number>; // questionId → -3..+3
/** Step 1: Sum raw signed scores per MBTI dimension */
function calcDimensionRaw(answers: Answers): Record<Dimension, number> {
const raw: Record<Dimension, number> = { EI: 0, SN: 0, TF: 0, JP: 0 };
for (const q of questions) {
const val = answers[q.id] ?? 0;
raw[q.dimension as Dimension] += val;
}
return raw;
}
/** Step 2: Normalise to 50–100 (50 = perfectly balanced) */
function normaliseDimension(raw: number, questionCount: number): number {
const max = questionCount * 3; // maximum possible absolute value
const clamped = Math.max(-max, Math.min(max, raw));
return Math.round(50 + (Math.abs(clamped) / max) * 50);
}
/** Step 3: Derive MBTI letter for one dimension */
function mbtiLetter(dimension: Dimension, raw: number): string {
const positive: Record<Dimension, string> = { EI: 'E', SN: 'N', TF: 'T', JP: 'J' };
const negative: Record<Dimension, string> = { EI: 'I', SN: 'S', TF: 'F', JP: 'P' };
return raw >= 0 ? positive[dimension] : negative[dimension];
}
/** Full pipeline */
export function computeResult(answers: Answers): QuizResult {
const dims: Dimension[] = ['EI', 'SN', 'TF', 'JP'];
const raw = calcDimensionRaw(answers);
// Count questions per dimension for normalisation
const countPerDim = dims.reduce((acc, d) => {
acc[d] = questions.filter(q => q.dimension === d).length;
return acc;
}, {} as Record<Dimension, number>);
const dimensionScores = dims.reduce((acc, d) => {
acc[d] = normaliseDimension(raw[d], countPerDim[d]);
return acc;
}, {} as Record<Dimension, number>);
const mbtiType = dims.map(d => mbtiLetter(d, raw[d])).join('');
// Match archetype (archetypes list mbtiTypes they cover)
const archetype = archetypes.find(a => a.mbtiTypes.includes(mbtiType))
?? archetypes[0];
// Pick best-fit character within archetype
const candidates = characters.filter(c => c.archetypeId === archetype.id);
// Default: first match; extendable with probability weighting
const character = candidates[0];
return {
mbtiType,
dimensionScores,
archetypeId: archetype.id,
characterId: character.id,
};
}该引擎是一个纯函数处理流程——是理想的扩展点。
typescript
import questions from '@/data/questions.json';
import archetypes from '@/data/archetypes.json';
import characters from '@/data/characters.json';
import type { Dimension, QuizResult } from '@/types/quiz';
type Answers = Record<number, number>; // 题目ID → -3..+3的答案值
/** 步骤1:计算每个MBTI维度的原始带符号得分 */
function calcDimensionRaw(answers: Answers): Record<Dimension, number> {
const raw: Record<Dimension, number> = { EI: 0, SN: 0, TF: 0, JP: 0 };
for (const q of questions) {
const val = answers[q.id] ?? 0;
raw[q.dimension as Dimension] += val;
}
return raw;
}
/** 步骤2:将得分归一化到50–100区间(50代表完全平衡) */
function normaliseDimension(raw: number, questionCount: number): number {
const max = questionCount * 3; // 最大可能的绝对值
const clamped = Math.max(-max, Math.min(max, raw));
return Math.round(50 + (Math.abs(clamped) / max) * 50);
}
/** 步骤3:推导单个维度对应的MBTI字母 */
function mbtiLetter(dimension: Dimension, raw: number): string {
const positive: Record<Dimension, string> = { EI: 'E', SN: 'N', TF: 'T', JP: 'J' };
const negative: Record<Dimension, string> = { EI: 'I', SN: 'S', TF: 'F', JP: 'P' };
return raw >= 0 ? positive[dimension] : negative[dimension];
}
/** 完整处理流程 */
export function computeResult(answers: Answers): QuizResult {
const dims: Dimension[] = ['EI', 'SN', 'TF', 'JP'];
const raw = calcDimensionRaw(answers);
// 统计每个维度的题目数量,用于归一化
const countPerDim = dims.reduce((acc, d) => {
acc[d] = questions.filter(q => q.dimension === d).length;
return acc;
}, {} as Record<Dimension, number>);
const dimensionScores = dims.reduce((acc, d) => {
acc[d] = normaliseDimension(raw[d], countPerDim[d]);
return acc;
}, {} as Record<Dimension, number>);
const mbtiType = dims.map(d => mbtiLetter(d, raw[d])).join('');
// 匹配原型(原型列表包含其覆盖的mbtiTypes)
const archetype = archetypes.find(a => a.mbtiTypes.includes(mbtiType))
?? archetypes[0];
// 在对应原型中选择最匹配的角色
const candidates = characters.filter(c => c.archetypeId === archetype.id);
// 默认选择第一个匹配项;可扩展为基于概率权重的选择
const character = candidates[0];
return {
mbtiType,
dimensionScores,
archetypeId: archetype.id,
characterId: character.id,
};
}Adding a New Character
添加新角色
Edit — append one object following the schema:
src/data/characters.jsonjson
{
"id": "hatsune-miku",
"name": "初音ミク",
"series": "VOCALOID",
"mbtiType": "ENFP",
"archetypeId": "chaotic-spark",
"tags": ["vocaloid", "energetic", "creative"],
"stats": {
"energy": 90,
"intuition": 85,
"empathy": 75,
"logic": 50,
"order": 40,
"chaos": 80
}
}Then add the matching visual entry to :
src/data/characterVisuals.jsonjson
{
"characterId": "hatsune-miku",
"portraitUrl": "https://your-cdn.example.com/miku-portrait.webp",
"backgroundUrl": "https://your-cdn.example.com/miku-bg.webp",
"primaryColor": "#39C5BB",
"accentColor": "#86EFDF"
}And an optional prior probability in :
src/data/characterProbabilities.jsonjson
{
"characterId": "hatsune-miku",
"baseProbability": 0.15
}Rules:
•must be unique and kebab-case.id
•must be one of the 16 standard types.mbtiType
•must match anarchetypeIdinid.archetypes.json
•values are integers 0–100.stats
编辑——按照以下模式追加一个对象:
src/data/characters.jsonjson
{
"id": "hatsune-miku",
"name": "初音ミク",
"series": "VOCALOID",
"mbtiType": "ENFP",
"archetypeId": "chaotic-spark",
"tags": ["vocaloid", "energetic", "creative"],
"stats": {
"energy": 90,
"intuition": 85,
"empathy": 75,
"logic": 50,
"order": 40,
"chaos": 80
}
}然后在中添加对应的视觉条目:
src/data/characterVisuals.jsonjson
{
"characterId": "hatsune-miku",
"portraitUrl": "https://your-cdn.example.com/miku-portrait.webp",
"backgroundUrl": "https://your-cdn.example.com/miku-bg.webp",
"primaryColor": "#39C5BB",
"accentColor": "#86EFDF"
}还可以在中添加可选的基础概率:
src/data/characterProbabilities.jsonjson
{
"characterId": "hatsune-miku",
"baseProbability": 0.15
}规则:
•必须唯一且采用短横线命名法(kebab-case)。id
•必须是16种标准MBTI类型之一。mbtiType
•必须与archetypeId中的某个archetypes.json完全匹配。id
•的值为0–100之间的整数。stats
Adding New Quiz Questions
添加新测试题目
Edit — append to the array:
src/data/questions.jsonjson
{
"id": 40,
"text": "在一个陌生的聚会上,你更倾向于主动找人搭话还是等别人来找你?",
"dimension": "EI",
"archetypeWeights": {
"glowing-protagonist": 2,
"ice-observer": -2,
"oath-captain": 1,
"agile-spinner": 1,
"gentle-healer": 0,
"shadow-strategist": -1,
"chaotic-spark": 2,
"moonlit-guardian": -1
},
"tags": ["social", "introvert-extrovert"]
}Guidelines:
- must be unique and increment sequentially.
id - is one of
dimension."EI" | "SN" | "TF" | "JP" - keys must match all 8 archetype
archetypeWeightsvalues; weights range -3 to +3.id - Positive weight = answer "strongly agree" nudges toward that archetype.
- Keep question text in Chinese (Simplified) to match existing copy.
编辑——向数组中追加题目:
src/data/questions.jsonjson
{
"id": 40,
"text": "在一个陌生的聚会上,你更倾向于主动找人搭话还是等别人来找你?",
"dimension": "EI",
"archetypeWeights": {
"glowing-protagonist": 2,
"ice-observer": -2,
"oath-captain": 1,
"agile-spinner": 1,
"gentle-healer": 0,
"shadow-strategist": -1,
"chaotic-spark": 2,
"moonlit-guardian": -1
},
"tags": ["social", "introvert-extrovert"]
}指南:
- 必须唯一且按顺序递增。
id - 必须是
dimension之一。"EI" | "SN" | "TF" | "JP" - 的键必须与全部8种原型的
archetypeWeights值匹配;权重范围为**-3至+3**。id - 正权重=选择“非常同意”会向该原型倾斜。
- 题目文本请使用简体中文,与现有内容保持一致。
Modifying Archetypes (src/data/archetypes.json
)
src/data/archetypes.json修改原型(src/data/archetypes.json
)
src/data/archetypes.jsonjson
{
"id": "glowing-protagonist",
"name": "发光主角位",
"mbtiTypes": ["ENFJ", "ENFP"],
"description": "天生的领袖与感召者,能点燃周围人的热情。",
"strengths": ["感召力强", "共情深刻", "行动力高"],
"weaknesses": ["容易过度承担", "情绪波动大"],
"color": "#FF6B6B"
}Each MBTI type (16 total) should appear in exactly one archetype'sarray. The engine uses a first-match lookup — gaps cause a fallback tombtiTypes.archetypes[0]
json
{
"id": "glowing-protagonist",
"name": "发光主角位",
"mbtiTypes": ["ENFJ", "ENFP"],
"description": "天生的领袖与感召者,能点燃周围人的热情。",
"strengths": ["感召力强", "共情深刻", "行动力高"],
"weaknesses": ["容易过度承担", "情绪波动大"],
"color": "#FF6B6B"
}每种MBTI类型(共16种)应恰好出现在一个原型的数组中。引擎采用首次匹配查找机制——若存在遗漏,会回退到mbtiTypes。archetypes[0]
useQuiz
Composable (state management)
useQuizuseQuiz
组合式函数(状态管理)
useQuiztypescript
// src/composables/useQuiz.ts — typical usage from a page component
import { useQuiz } from '@/composables/useQuiz';
const {
currentQuestion, // Ref<Question>
currentIndex, // Ref<number>
totalQuestions, // number (39)
progress, // ComputedRef<number> 0–100
answer, // (value: number) => void — records -3..+3 and advances
goBack, // () => void
result, // Ref<QuizResult | null>
isComplete, // ComputedRef<boolean>
resetQuiz, // () => void
} = useQuiz();typescript
// src/composables/useQuiz.ts —— 页面组件中的典型用法
import { useQuiz } from '@/composables/useQuiz';
const {
currentQuestion, // Ref<Question> 当前题目
currentIndex, // Ref<number> 当前题目索引
totalQuestions, // number 题目总数(39道)
progress, // ComputedRef<number> 完成进度0–100
answer, // (value: number) => void 记录答案(-3..+3)并跳转到下一题
goBack, // () => void 返回上一题
result, // Ref<QuizResult | null> 测试结果
isComplete, // ComputedRef<boolean> 测试是否完成
resetQuiz, // () => void 重置测试
} = useQuiz();Share / Export Poster (useShare
)
useShare分享/导出海报(useShare
)
useSharetypescript
import { useShare } from '@/composables/useShare';
const { exportPNG, shareNative } = useShare();
// exportPNG wraps html2canvas on the #share-poster element
await exportPNG('#share-poster', 'my-acgti-result.png');
// shareNative uses Web Share API with fallback to clipboard copy
await shareNative({
title: 'My ACGTI Result',
text: `I got ${result.value?.characterId}!`,
url: 'https://acgti.tianxingleo.top',
});typescript
import { useShare } from '@/composables/useShare';
const { exportPNG, shareNative } = useShare();
// exportPNG基于html2canvas处理#share-poster元素
await exportPNG('#share-poster', 'my-acgti-result.png');
// shareNative使用Web Share API, fallback到剪贴板复制
await shareNative({
title: 'My ACGTI Result',
text: `I got ${result.value?.characterId}!`,
url: 'https://acgti.tianxingleo.top',
});Routing (src/router/index.ts
)
src/router/index.ts路由(src/router/index.ts
)
src/router/index.tstypescript
// Five named routes
const routes = [
{ path: '/', name: 'home', component: HomePage },
{ path: '/intro', name: 'intro', component: IntroPage },
{ path: '/quiz', name: 'quiz', component: QuizPage },
{ path: '/result', name: 'result', component: ResultPage },
{ path: '/characters',name: 'characters', component: CharactersPage },
{ path: '/about', name: 'about', component: AboutPage },
];Navigate programmatically after quiz completion:
typescript
import { useRouter } from 'vue-router';
const router = useRouter();
router.push({ name: 'result' });typescript
// 五个命名路由
const routes = [
{ path: '/', name: 'home', component: HomePage },
{ path: '/intro', name: 'intro', component: IntroPage },
{ path: '/quiz', name: 'quiz', component: QuizPage },
{ path: '/result', name: 'result', component: ResultPage },
{ path: '/characters',name: 'characters', component: CharactersPage },
{ path: '/about', name: 'about', component: AboutPage },
];测试完成后编程式导航:
typescript
import { useRouter } from 'vue-router';
const router = useRouter();
router.push({ name: 'result' });localStorage Utilities (src/utils/storage.ts
)
src/utils/storage.tslocalStorage工具函数(src/utils/storage.ts
)
src/utils/storage.tstypescript
import { saveResult, loadResult, clearResult } from '@/utils/storage';
import type { QuizResult } from '@/types/quiz';
// Persist result across page refreshes
saveResult(result);
// Restore on ResultPage mount
const saved: QuizResult | null = loadResult();
// Reset for retake
clearResult();typescript
import { saveResult, loadResult, clearResult } from '@/utils/storage';
import type { QuizResult } from '@/types/quiz';
// 持久化结果,页面刷新后依然保留
saveResult(result);
// 在ResultPage挂载时恢复结果
const saved: QuizResult | null = loadResult();
// 重置结果以重新测试
clearResult();Deployment
部署
Cloudflare Pages (recommended)
Cloudflare Pages(推荐)
- Connect GitHub repo → Cloudflare Pages dashboard.
- Build command:
npm run build - Build output directory:
dist - No environment variables required (pure frontend).
- 连接GitHub仓库到Cloudflare Pages控制台。
- 构建命令:
npm run build - 构建输出目录:
dist - 无需环境变量(纯前端应用)。
GitHub Actions CI
GitHub Actions持续集成
The repo includes a workflow that runs on every push to / and on PRs:
maindevyaml
undefined仓库包含一个工作流,会在每次推送到/分支以及PR时运行:
maindevyaml
undefined.github/workflows/ci.yml (existing)
.github/workflows/ci.yml(已存在)
- run: npm ci
- run: npm run build
undefined- run: npm ci
- run: npm run build
undefinedRelease a version
发布版本
bash
git tag v1.2.0
git push origin v1.2.0bash
git tag v1.2.0
git push origin v1.2.0GitHub Actions auto-builds dist/, zips it, creates a Release
GitHub Actions会自动构建dist/,打包并创建Release
---
---Common Patterns & Tips
通用模式与技巧
Filtering characters by archetype in a component
在组件中按原型筛选角色
typescript
import characters from '@/data/characters.json';
import type { Character } from '@/types/quiz';
const archetypeId = 'glowing-protagonist';
const subset: Character[] = characters.filter(
(c) => c.archetypeId === archetypeId
);typescript
import characters from '@/data/characters.json';
import type { Character } from '@/types/quiz';
const archetypeId = 'glowing-protagonist';
const subset: Character[] = characters.filter(
(c) => c.archetypeId === archetypeId
);Accessing visuals by character ID
通过角色ID获取视觉资源
typescript
import visuals from '@/data/characterVisuals.json';
import { enrichCharacterVisuals } from '@/utils/characterVisuals';
const enriched = enrichCharacterVisuals(characters, visuals);
// enriched[i] = { ...Character, ...CharacterVisual }typescript
import visuals from '@/data/characterVisuals.json';
import { enrichCharacterVisuals } from '@/utils/characterVisuals';
const enriched = enrichCharacterVisuals(characters, visuals);
// enriched[i] = { ...Character, ...CharacterVisual }Reactive dimension label (E vs I, etc.)
响应式维度标签(E vs I等)
typescript
function dimensionLabel(dim: Dimension, score: number): string {
const labels: Record<Dimension, [string, string]> = {
EI: ['E 外向', 'I 内向'],
SN: ['N 直觉', 'S 实感'],
TF: ['T 思考', 'F 情感'],
JP: ['J 判断', 'P 知觉'],
};
// score > 50 means positive pole; score === 50 means balanced (show both)
return score >= 50 ? labels[dim][0] : labels[dim][1];
}typescript
function dimensionLabel(dim: Dimension, score: number): string {
const labels: Record<Dimension, [string, string]> = {
EI: ['E 外向', 'I 内向'],
SN: ['N 直觉', 'S 实感'],
TF: ['T 思考', 'F 情感'],
JP: ['J 判断', 'P 知觉'],
};
// 得分>50代表正向维度;得分===50代表平衡状态(显示两者)
return score >= 50 ? labels[dim][0] : labels[dim][1];
}Troubleshooting
故障排查
| Symptom | Likely cause | Fix |
|---|---|---|
| New JSON data doesn't match types | Run |
| Character not appearing in results | | Ensure |
| New question not affecting scores | | Must be exactly |
| Poster export is blank | | Host character images on a CORS-enabled CDN or use base64 data URIs |
| Route returns 404 on Cloudflare Pages | SPA fallback not configured | Add |
Dev server errors on | Vite alias not resolving | Check |
| 症状 | 可能原因 | 修复方法 |
|---|---|---|
| 新增的JSON数据与类型定义不匹配 | 运行 |
| 角色未出现在测试结果中 | | 确保 |
| 新题目不影响得分 | | 必须是 |
| 海报导出为空 | | 将角色图片托管在支持CORS的CDN上,或使用base64数据URI |
| Cloudflare Pages上路由返回404 | 未配置SPA回退规则 | 在 |
开发服务器在 | Vite别名未解析 | 检查 |