feat: 支持评分字段的导入
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -99,3 +99,26 @@ COS_APPID=
|
|||||||
- Password hashing must use `hashPassword` from `better-auth/crypto` for compatibility
|
- Password hashing must use `hashPassword` from `better-auth/crypto` for compatibility
|
||||||
- Session model requires `token` field with unique constraint
|
- Session model requires `token` field with unique constraint
|
||||||
- **Git commit messages must be written in Chinese**
|
- **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 里分两步走。
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,21 @@ function parseRiddleId(raw: unknown): string | null {
|
|||||||
return riddleId
|
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 {
|
function optionalString(raw: unknown): string | null {
|
||||||
return raw ? String(raw) : null
|
return raw ? String(raw) : null
|
||||||
}
|
}
|
||||||
@@ -85,6 +100,8 @@ function buildLevelData(body: Record<string, unknown>) {
|
|||||||
hint1: optionalString(body.hint1),
|
hint1: optionalString(body.hint1),
|
||||||
hint2: optionalString(body.hint2),
|
hint2: optionalString(body.hint2),
|
||||||
hint3: optionalString(body.hint3),
|
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 position: number | undefined
|
||||||
let riddleId: string | null
|
let riddleId: string | null
|
||||||
|
let data: ReturnType<typeof buildLevelData>
|
||||||
try {
|
try {
|
||||||
position = parsePosition(body.position)
|
position = parsePosition(body.position)
|
||||||
riddleId = parseRiddleId(body.riddleId)
|
riddleId = parseRiddleId(body.riddleId)
|
||||||
|
data = buildLevelData(body)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e instanceof Error ? e.message : 'invalid level payload' },
|
{ 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) {
|
if (riddleId) {
|
||||||
const existing = await prisma.level.findUnique({
|
const existing = await prisma.level.findUnique({
|
||||||
where: { riddleId },
|
where: { riddleId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
let newSortKey: string | undefined
|
// 命中存量关卡 → 只更新内容字段,绝不动 sortKey / sortOrder。
|
||||||
if (position !== undefined) {
|
// 关卡顺序由用户在系统里手动维护,批量导入不能覆盖。
|
||||||
const total = await prisma.level.count()
|
// 注意:position 在此分支被显式忽略(即便客户端误传也不会被处理)。
|
||||||
if (position > total) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `position 超出范围,合法区间 [1, ${total}]` },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
newSortKey = await keyForPosition(position, existing.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = await prisma.level.update({
|
const level = await prisma.level.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data, // buildLevelData 不包含 sortKey
|
||||||
...data,
|
|
||||||
...(newSortKey ? { sortKey: newSortKey } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(level)
|
return NextResponse.json(level)
|
||||||
@@ -207,11 +212,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let position: number | undefined
|
let position: number | undefined
|
||||||
|
let data: ReturnType<typeof buildLevelData>
|
||||||
try {
|
try {
|
||||||
position = parsePosition(body.position)
|
position = parsePosition(body.position)
|
||||||
|
data = buildLevelData(body)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e instanceof Error ? e.message : 'invalid position' },
|
{ error: e instanceof Error ? e.message : 'invalid level payload' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -229,8 +236,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
newSortKey = await keyForPosition(position, id)
|
newSortKey = await keyForPosition(position, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = buildLevelData(body)
|
|
||||||
|
|
||||||
const level = await prisma.level.update({
|
const level = await prisma.level.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ interface ParsedItem {
|
|||||||
hint1: string
|
hint1: string
|
||||||
hint2: string
|
hint2: string
|
||||||
hint3: string
|
hint3: string
|
||||||
|
// 评分(1-5,可空)
|
||||||
|
difficultyScore: number | null
|
||||||
|
funScore: number | null
|
||||||
// 解析警告/错误信息
|
// 解析警告/错误信息
|
||||||
parseError?: string
|
parseError?: string
|
||||||
// 上传/创建过程状态
|
// 上传/创建过程状态
|
||||||
@@ -76,6 +79,18 @@ interface RiddleConfigItem {
|
|||||||
anchor_text?: string
|
anchor_text?: string
|
||||||
reference_image?: string
|
reference_image?: string
|
||||||
riddle_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 {
|
function truncate(s: string | undefined, max: number): string {
|
||||||
@@ -181,6 +196,8 @@ async function parseConfigJson(
|
|||||||
hint1: (hints[0] || '').trim(),
|
hint1: (hints[0] || '').trim(),
|
||||||
hint2: (hints[1] || '').trim(),
|
hint2: (hints[1] || '').trim(),
|
||||||
hint3: (hints[2] || '').trim(),
|
hint3: (hints[2] || '').trim(),
|
||||||
|
difficultyScore: parseScoreField(config.difficulty_score),
|
||||||
|
funScore: parseScoreField(config.fun_score),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +267,8 @@ async function parseFolders(files: FileList): Promise<ParsedItem[]> {
|
|||||||
hint1: '',
|
hint1: '',
|
||||||
hint2: '',
|
hint2: '',
|
||||||
hint3: '',
|
hint3: '',
|
||||||
|
difficultyScore: null,
|
||||||
|
funScore: null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +431,11 @@ export function BatchImportDialog({
|
|||||||
hint1: it.hint1 || '',
|
hint1: it.hint1 || '',
|
||||||
hint2: it.hint2 || '',
|
hint2: it.hint2 || '',
|
||||||
hint3: it.hint3 || '',
|
hint3: it.hint3 || '',
|
||||||
|
difficultyScore: it.difficultyScore,
|
||||||
|
funScore: it.funScore,
|
||||||
|
// 故意不传 position / sortKey。
|
||||||
|
// 命中存量关卡时,POST /api/levels 的 riddleId-update 分支会忽略
|
||||||
|
// 这两个字段以保护用户手动维护的关卡顺序。
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiFetch('/api/levels', {
|
const res = await apiFetch('/api/levels', {
|
||||||
@@ -695,6 +719,45 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">难度评分 (1-5)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={item.difficultyScore ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
difficultyScore:
|
||||||
|
e.target.value === '' ? null : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">有趣度 (1-5)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={item.funScore ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
funScore:
|
||||||
|
e.target.value === '' ? null : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ interface LevelDialogProps {
|
|||||||
onSubmit: (data: LevelFormData) => Promise<void>
|
onSubmit: (data: LevelFormData) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormState = Omit<LevelFormData, 'position'> & {
|
type FormState = Omit<LevelFormData, 'position' | 'difficultyScore' | 'funScore'> & {
|
||||||
/** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */
|
/** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */
|
||||||
position: string
|
position: string
|
||||||
|
/** 评分字段以字符串保存;空串 = 未评分 */
|
||||||
|
difficultyScore: string
|
||||||
|
funScore: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFormState: FormState = {
|
const defaultFormState: FormState = {
|
||||||
@@ -42,6 +45,8 @@ const defaultFormState: FormState = {
|
|||||||
hint1: '',
|
hint1: '',
|
||||||
hint2: '',
|
hint2: '',
|
||||||
hint3: '',
|
hint3: '',
|
||||||
|
difficultyScore: '',
|
||||||
|
funScore: '',
|
||||||
position: '',
|
position: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +90,9 @@ export function LevelDialog({
|
|||||||
hint1: level.hint1 || '',
|
hint1: level.hint1 || '',
|
||||||
hint2: level.hint2 || '',
|
hint2: level.hint2 || '',
|
||||||
hint3: level.hint3 || '',
|
hint3: level.hint3 || '',
|
||||||
|
difficultyScore:
|
||||||
|
level.difficultyScore != null ? String(level.difficultyScore) : '',
|
||||||
|
funScore: level.funScore != null ? String(level.funScore) : '',
|
||||||
position: String(defaultPos),
|
position: String(defaultPos),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -126,6 +134,21 @@ export function LevelDialog({
|
|||||||
return
|
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
|
let position: number | undefined
|
||||||
const raw = formData.position.trim()
|
const raw = formData.position.trim()
|
||||||
@@ -153,6 +176,8 @@ export function LevelDialog({
|
|||||||
hint1: formData.hint1,
|
hint1: formData.hint1,
|
||||||
hint2: formData.hint2,
|
hint2: formData.hint2,
|
||||||
hint3: formData.hint3,
|
hint3: formData.hint3,
|
||||||
|
difficultyScore,
|
||||||
|
funScore,
|
||||||
...(position !== undefined ? { position } : {}),
|
...(position !== undefined ? { position } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +341,45 @@ export function LevelDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="difficultyScore">难度评分 (1-5,可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="difficultyScore"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={formData.difficultyScore}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
difficultyScore: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="1-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="funScore">有趣度 (1-5,可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="funScore"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={formData.funScore}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
funScore: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="1-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Pencil, Trash2 } from 'lucide-react'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
|
||||||
// 列宽定义,header 和 row 必须保持一致。用 grid-template-columns 统一控制。
|
// 列宽定义,header 和 row 必须保持一致。用 grid-template-columns 统一控制。
|
||||||
// 序号 | 图片 | 答案 | 谐音梗 | 提示 | 创建时间 | 操作
|
// 序号 | 图片 | 答案 | 谐音梗 | 提示 | 创建时间 | 评分 | 操作
|
||||||
export const GRID_TEMPLATE =
|
export const GRID_TEMPLATE =
|
||||||
'minmax(60px,60px) minmax(120px,120px) minmax(80px,1fr) minmax(100px,1fr) minmax(160px,2fr) minmax(100px,100px) minmax(100px,100px)'
|
'minmax(60px,60px) minmax(120px,120px) minmax(80px,1fr) minmax(100px,1fr) minmax(160px,2fr) minmax(100px,100px) minmax(110px,110px) minmax(100px,100px)'
|
||||||
|
|
||||||
interface LevelRowProps {
|
interface LevelRowProps {
|
||||||
level: Level
|
level: Level
|
||||||
@@ -91,6 +91,13 @@ export function LevelRow({ level, index, onEdit, onDelete }: LevelRowProps) {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* 评分 */}
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{level.difficultyScore != null || level.funScore != null
|
||||||
|
? `难度 ${level.difficultyScore ?? '-'} · 有趣 ${level.funScore ?? '-'}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* 操作 */}
|
{/* 操作 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const HEADER_COLUMNS = [
|
|||||||
'谐音梗',
|
'谐音梗',
|
||||||
'提示',
|
'提示',
|
||||||
'创建时间',
|
'创建时间',
|
||||||
|
'评分',
|
||||||
'操作',
|
'操作',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ model Level {
|
|||||||
hint1 String?
|
hint1 String?
|
||||||
hint2 String?
|
hint2 String?
|
||||||
hint3 String?
|
hint3 String?
|
||||||
|
difficultyScore Int? @map("difficulty_score") @db.TinyInt
|
||||||
|
funScore Int? @map("fun_score") @db.TinyInt
|
||||||
sortKey String @default("a0") @map("sort_key") @db.VarChar(64)
|
sortKey String @default("a0") @map("sort_key") @db.VarChar(64)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface Level {
|
|||||||
hint1: string | null
|
hint1: string | null
|
||||||
hint2: string | null
|
hint2: string | null
|
||||||
hint3: string | null
|
hint3: string | null
|
||||||
|
difficultyScore: number | null
|
||||||
|
funScore: number | null
|
||||||
sortKey: string
|
sortKey: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
@@ -27,6 +29,8 @@ export interface LevelFormData {
|
|||||||
hint1?: string
|
hint1?: string
|
||||||
hint2?: string
|
hint2?: string
|
||||||
hint3?: string
|
hint3?: string
|
||||||
|
difficultyScore?: number | null
|
||||||
|
funScore?: number | null
|
||||||
/** 1-based 位置;创建时 [1, N+1],编辑时 [1, N];不传由后端决定默认行为 */
|
/** 1-based 位置;创建时 [1, N+1],编辑时 [1, N];不传由后端决定默认行为 */
|
||||||
position?: number
|
position?: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user