From 588b6fbc77ed4fe81cc3bb5db2aacd9d22764b2b Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 7 Jun 2026 09:50:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=AF=84=E5=88=86?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9A=84=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 23 ++++++++ app/api/levels/route.ts | 47 ++++++++-------- components/levels/batch-import-dialog.tsx | 63 ++++++++++++++++++++++ components/levels/level-dialog.tsx | 66 ++++++++++++++++++++++- components/levels/level-row.tsx | 11 +++- components/levels/level-table.tsx | 1 + prisma/schema.prisma | 2 + types/index.ts | 4 ++ 8 files changed, 193 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9de1835..c4fdf1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,3 +99,26 @@ COS_APPID= - Password hashing must use `hashPassword` from `better-auth/crypto` for compatibility - Session model requires `token` field with unique constraint - **Git commit messages must be written in Chinese** + +### Database Schema Changes + +涉及 Prisma schema 修改时,**不要自动跑 `pnpm run db:push` 或 `prisma db push`**。 + +- 改完 `prisma/schema.prisma` 后,根据 schema 写出对应的 MySQL `ALTER TABLE` 语句 + 给用户,让用户手动在 dev / prod 数据库上执行 +- 跑 `pnpm run db:generate` 让本地 Prisma client 同步类型(这一步是 OK 的) +- 部署脚本 `./deploy.sh` 也只跑 `npx prisma generate`(只重生 client),不会自动 + apply schema,所以线上库也需要用户手动执行 SQL +- 理由:schema 变更需要人工 review(数据迁移、默认值、索引取舍),不应该由 + Agent 在没有确认的情况下直接改库 + +**新增可空字段示例**(本次 difficulty_score / fun_score): + +```sql +ALTER TABLE levels + ADD COLUMN difficulty_score TINYINT NULL, + ADD COLUMN fun_score TINYINT NULL; +``` + +新增非空字段需要同步给出现有行填默认值,需要在 SQL 里分两步走。 + diff --git a/app/api/levels/route.ts b/app/api/levels/route.ts index 6f291b1..3dea721 100644 --- a/app/api/levels/route.ts +++ b/app/api/levels/route.ts @@ -70,6 +70,21 @@ function parseRiddleId(raw: unknown): string | null { return riddleId } +/** + * 解析评分: + * - undefined / null / '' → null(视为未评分) + * - 1-5 整数 → 原样返回 + * - 其它 → 抛错 + */ +function parseScore(raw: unknown): number | null { + if (raw === undefined || raw === null || raw === '') return null + const n = typeof raw === 'number' ? raw : Number(raw) + if (!Number.isInteger(n) || n < 1 || n > 5) { + throw new Error('评分必须是 1-5 之间的整数') + } + return n +} + function optionalString(raw: unknown): string | null { return raw ? String(raw) : null } @@ -85,6 +100,8 @@ function buildLevelData(body: Record) { hint1: optionalString(body.hint1), hint2: optionalString(body.hint2), hint3: optionalString(body.hint3), + difficultyScore: parseScore(body.difficultyScore), + funScore: parseScore(body.funScore), } } @@ -112,9 +129,11 @@ export async function POST(request: NextRequest) { let position: number | undefined let riddleId: string | null + let data: ReturnType try { position = parsePosition(body.position) riddleId = parseRiddleId(body.riddleId) + data = buildLevelData(body) } catch (e) { return NextResponse.json( { error: e instanceof Error ? e.message : 'invalid level payload' }, @@ -122,32 +141,18 @@ export async function POST(request: NextRequest) { ) } - const data = buildLevelData(body) - if (riddleId) { const existing = await prisma.level.findUnique({ where: { riddleId }, }) if (existing) { - let newSortKey: string | undefined - if (position !== undefined) { - const total = await prisma.level.count() - if (position > total) { - return NextResponse.json( - { error: `position 超出范围,合法区间 [1, ${total}]` }, - { status: 400 } - ) - } - newSortKey = await keyForPosition(position, existing.id) - } - + // 命中存量关卡 → 只更新内容字段,绝不动 sortKey / sortOrder。 + // 关卡顺序由用户在系统里手动维护,批量导入不能覆盖。 + // 注意:position 在此分支被显式忽略(即便客户端误传也不会被处理)。 const level = await prisma.level.update({ where: { id: existing.id }, - data: { - ...data, - ...(newSortKey ? { sortKey: newSortKey } : {}), - }, + data, // buildLevelData 不包含 sortKey }) return NextResponse.json(level) @@ -207,11 +212,13 @@ export async function PUT(request: NextRequest) { } let position: number | undefined + let data: ReturnType try { position = parsePosition(body.position) + data = buildLevelData(body) } catch (e) { return NextResponse.json( - { error: e instanceof Error ? e.message : 'invalid position' }, + { error: e instanceof Error ? e.message : 'invalid level payload' }, { status: 400 } ) } @@ -229,8 +236,6 @@ export async function PUT(request: NextRequest) { newSortKey = await keyForPosition(position, id) } - const data = buildLevelData(body) - const level = await prisma.level.update({ where: { id }, data: { diff --git a/components/levels/batch-import-dialog.tsx b/components/levels/batch-import-dialog.tsx index 448dd29..aab5a87 100644 --- a/components/levels/batch-import-dialog.tsx +++ b/components/levels/batch-import-dialog.tsx @@ -44,6 +44,9 @@ interface ParsedItem { hint1: string hint2: string hint3: string + // 评分(1-5,可空) + difficultyScore: number | null + funScore: number | null // 解析警告/错误信息 parseError?: string // 上传/创建过程状态 @@ -76,6 +79,18 @@ interface RiddleConfigItem { anchor_text?: string reference_image?: string riddle_image?: string + // JSON 里通常是字符串,容忍数字 + difficulty_score?: string | number + fun_score?: string | number +} + +/** + * 解析评分字段,失败/缺失静默置 null,导入流程不因此阻塞。 + */ +function parseScoreField(raw: string | number | undefined): number | null { + if (raw === undefined || raw === null || raw === '') return null + const n = typeof raw === 'number' ? raw : Number(raw) + return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null } function truncate(s: string | undefined, max: number): string { @@ -181,6 +196,8 @@ async function parseConfigJson( hint1: (hints[0] || '').trim(), hint2: (hints[1] || '').trim(), hint3: (hints[2] || '').trim(), + difficultyScore: parseScoreField(config.difficulty_score), + funScore: parseScoreField(config.fun_score), status: 'pending', } @@ -250,6 +267,8 @@ async function parseFolders(files: FileList): Promise { hint1: '', hint2: '', hint3: '', + difficultyScore: null, + funScore: null, status: 'pending', } @@ -412,6 +431,11 @@ export function BatchImportDialog({ hint1: it.hint1 || '', hint2: it.hint2 || '', hint3: it.hint3 || '', + difficultyScore: it.difficultyScore, + funScore: it.funScore, + // 故意不传 position / sortKey。 + // 命中存量关卡时,POST /api/levels 的 riddleId-update 分支会忽略 + // 这两个字段以保护用户手动维护的关卡顺序。 } const res = await apiFetch('/api/levels', { @@ -695,6 +719,45 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) /> + +
+
+ + + onChange({ + difficultyScore: + e.target.value === '' ? null : Number(e.target.value), + }) + } + disabled={disabled} + placeholder="可选" + /> +
+
+ + + onChange({ + funScore: + e.target.value === '' ? null : Number(e.target.value), + }) + } + disabled={disabled} + placeholder="可选" + /> +
+
diff --git a/components/levels/level-dialog.tsx b/components/levels/level-dialog.tsx index d9569a3..cc413b8 100644 --- a/components/levels/level-dialog.tsx +++ b/components/levels/level-dialog.tsx @@ -27,9 +27,12 @@ interface LevelDialogProps { onSubmit: (data: LevelFormData) => Promise } -type FormState = Omit & { +type FormState = Omit & { /** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */ position: string + /** 评分字段以字符串保存;空串 = 未评分 */ + difficultyScore: string + funScore: string } const defaultFormState: FormState = { @@ -42,6 +45,8 @@ const defaultFormState: FormState = { hint1: '', hint2: '', hint3: '', + difficultyScore: '', + funScore: '', position: '', } @@ -85,6 +90,9 @@ export function LevelDialog({ hint1: level.hint1 || '', hint2: level.hint2 || '', hint3: level.hint3 || '', + difficultyScore: + level.difficultyScore != null ? String(level.difficultyScore) : '', + funScore: level.funScore != null ? String(level.funScore) : '', position: String(defaultPos), }) } else { @@ -126,6 +134,21 @@ export function LevelDialog({ return } + // 评分:空串 → null;非空必须是 1-5 整数 + const parseScoreField = (raw: string): number | null | undefined => { + const trimmed = raw.trim() + if (trimmed === '') return null + const n = Number(trimmed) + if (!Number.isInteger(n) || n < 1 || n > 5) return undefined + return n + } + const difficultyScore = parseScoreField(formData.difficultyScore) + const funScore = parseScoreField(formData.funScore) + if (difficultyScore === undefined || funScore === undefined) { + setError('难度/有趣度评分必须是 1-5 之间的整数') + return + } + // 位置:留空视为"不改位置"(编辑)/ "追加末尾"(创建) let position: number | undefined const raw = formData.position.trim() @@ -153,6 +176,8 @@ export function LevelDialog({ hint1: formData.hint1, hint2: formData.hint2, hint3: formData.hint3, + difficultyScore, + funScore, ...(position !== undefined ? { position } : {}), } @@ -316,6 +341,45 @@ export function LevelDialog({ /> +
+
+ + + setFormData((prev) => ({ + ...prev, + difficultyScore: e.target.value, + })) + } + placeholder="1-5" + /> +
+
+ + + setFormData((prev) => ({ + ...prev, + funScore: e.target.value, + })) + } + placeholder="1-5" + /> +
+
+