# 家长端成绩与试卷管理 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 家长端可管理每周检测成绩、考试成绩记录(编辑分数/达标、删除)与考试试卷内容(增改删、题目 JSON 校验)。 **Architecture:** 零新表零迁移。4 个任务:① parent/actions.ts 加 6 个管理 action(身份+家庭归属校验,试卷 JSON 形状校验);② 周测成绩管理页 /parent/weekly-records(替换课程管理只读块为入口卡片,移除 page.tsx weekly 查询);③ 考试成绩记录管理页 /parent/exam-records(左联试卷标题,卷删显示「已删试卷」);④ 考试试卷管理页 /parent/exams(列表/新建/编辑,仿 math 编辑器,JSON 文本域 + 保存校验)。所有页面 child 端行为零改动。 **Tech Stack:** Next.js 15.5 App Router、Drizzle ORM(mysql2)、shadcn ui 组件(Button/Input/Label/Checkbox/Badge)、zod、`useTransition` + `useRouter().refresh()` 行内编辑。 **Spec:** `docs/superpowers/specs/2026-08-19-parent-exam-manage-design.md` ## Global Constraints - 项目无自动化测试框架,验证 = `npx tsc --noEmit`(每任务)+ `npm run build`(最终任务)。 - 新代码注释遵循项目风格,新函数加一行 `// xuchao:` 注释。 - 所有管理 action:家长身份校验(`!parent || parent.role !== "parent"`),并校验记录/孩子归属 `familyId === parent.familyId`——防越权。 - 编辑成绩/达标**不联动**积分/打卡回滚(用户选定):只改成绩表本身。 - 分数编辑限制:整数 0-1000。 - 删除操作一律 `confirm()` 二次确认(文案含被删对象)。 - 题目 JSON 校验规则:数组、非空、每题必需 `stem`/`type`/`explain`,题型条件字段(choice/judge 需 `answer`;fill 需 `answers` 数组;order 需 `answer` 数组;match 需 `pairs` 数组;solve 需 `reference` + `points > 0`)。 - `exam_paper` 的 `paperId` 有唯一索引 `uq_exam_paper_id`,保存用 `insert().onDuplicateKeyUpdate()`(参照 weekly/actions.ts 同款用法)。 - 课程管理 Section 只读周测块 → 三张入口卡片,风格与「✏️ 编辑英语语法课程」卡片一致(`rounded-xl border ... hover:-translate-y-0.5`)。 --- ### Task 1: 家长端 6 个管理 action **Files:** - Modify: `src/app/parent/actions.ts`(顶部 schema 导入补 `examPapers/examRecords/weeklyTest`;文件末尾新增 6 个 action + 1 个私有校验函数) **Interfaces:** - Consumes: 现有 `getSessionUser`/`db`/`users`/`revalidatePath`/`eq`/`and`;(schema)新增导入 `examPapers`/`examRecords`/`weeklyTest`。 - Produces: - `updateWeeklyRecord(id: number, score: number, passed: boolean): Promise` — 成功返回 null,错误返回文案 - `deleteWeeklyRecord(id: number): Promise` - `updateExamRecord(id: number, score: number, passed: boolean): Promise` - `deleteExamRecord(id: number): Promise` - `saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }>` - `deleteExamPaper(paperId: string): Promise` - [ ] **Step 1: schema 导入行补三张表** `src/app/parent/actions.ts` 顶部 `import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema";` 改为: ```ts import { activationCodes, examPapers, examRecords, sessions, taskInstances, taskTemplates, users, weeklyTest } from "@/db/schema"; ``` - [ ] **Step 2: 文件末尾追加三组 action 与校验函数** ```ts // ===== 成绩与试卷管理:周测记录(不联动积分/打卡) ===== export async function updateWeeklyRecord(id: number, score: number, passed: boolean): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return "请先以家长身份登录"; if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数"; const [row] = await db .select({ familyId: users.familyId }) .from(weeklyTest) .innerJoin(users, eq(users.id, weeklyTest.childUserId)) .where(eq(weeklyTest.id, id)) .limit(1); if (!row || row.familyId !== parent.familyId) return "记录不存在"; await db.update(weeklyTest).set({ score, passed }).where(eq(weeklyTest.id, id)); revalidatePath("/parent/weekly-records"); revalidatePath("/parent"); return null; } export async function deleteWeeklyRecord(id: number): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return; const [row] = await db .select({ familyId: users.familyId }) .from(weeklyTest) .innerJoin(users, eq(users.id, weeklyTest.childUserId)) .where(eq(weeklyTest.id, id)) .limit(1); if (!row || row.familyId !== parent.familyId) return; await db.delete(weeklyTest).where(eq(weeklyTest.id, id)); revalidatePath("/parent/weekly-records"); revalidatePath("/parent"); } // ===== 成绩与试卷管理:考试记录(不联动积分/打卡) ===== export async function updateExamRecord(id: number, score: number, passed: boolean): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return "请先以家长身份登录"; if (!Number.isInteger(score) || score < 0 || score > 1000) return "分数需为 0-1000 的整数"; const [row] = await db .select({ familyId: users.familyId }) .from(examRecords) .innerJoin(users, eq(users.id, examRecords.childUserId)) .where(eq(examRecords.id, id)) .limit(1); if (!row || row.familyId !== parent.familyId) return "记录不存在"; await db.update(examRecords).set({ score, passed }).where(eq(examRecords.id, id)); revalidatePath("/parent/exam-records"); revalidatePath("/parent"); return null; } export async function deleteExamRecord(id: number): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return; const [row] = await db .select({ familyId: users.familyId }) .from(examRecords) .innerJoin(users, eq(users.id, examRecords.childUserId)) .where(eq(examRecords.id, id)) .limit(1); if (!row || row.familyId !== parent.familyId) return; await db.delete(examRecords).where(eq(examRecords.id, id)); revalidatePath("/parent/exam-records"); revalidatePath("/parent"); } // xuchao: 试卷题目 JSON 形状校验:数组 + 每题必需字段 + 题型条件字段 const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"]; function validateQuestionsJson(raw: string): { error?: string; count?: number } { let arr: unknown; try { arr = JSON.parse(raw); } catch { return { error: "题目 JSON 解析失败,请检查语法" }; } if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" }; if (arr.length === 0) return { error: "试卷至少需要 1 道题" }; for (let i = 0; i < arr.length; i++) { const q = arr[i] as Record; const no = `第 ${i + 1} 题`; if (!q || typeof q !== "object") return { error: `${no}不是对象` }; if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` }; if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` }; const t = q.type; if (!EXAM_TYPES.includes(t as string)) return { error: `${no}的 type 非法:${String(t)}` }; if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` }; if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` }; if (t === "order" && !Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` }; if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` }; if (t === "solve") { if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` }; if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` }; } } return { count: arr.length }; } // xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效) export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" }; const f = (k: string) => String(formData.get(k) ?? "").trim(); const paperId = f("paperId"); if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号只允许小写字母/数字/中划线,如 g7s1-m09-2" }; const title = f("title"); if (!title) return { error: "请填写试卷标题" }; const month = Number(formData.get("month")); const seq = Number(formData.get("seq")); if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" }; if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" }; const questions = f("questions"); const chk = validateQuestionsJson(questions); if (chk.error) return { error: chk.error }; await db .insert(examPapers) .values({ paperId, subject: f("subject") || "math", grade: f("grade") || "g7", semester: f("semester") || "s1", month, examType: f("examType"), title, region: f("region") || null, year: Number(formData.get("year")) || null, duration: Number(formData.get("duration")) || null, fullScore: Number(formData.get("fullScore")) || null, sourceUrl: f("sourceUrl") || null, summary: f("summary") || null, questions: JSON.stringify(JSON.parse(questions)), seq, }) .onDuplicateKeyUpdate({ set: { subject: f("subject") || "math", grade: f("grade") || "g7", semester: f("semester") || "s1", month, examType: f("examType"), title, region: f("region") || null, year: Number(formData.get("year")) || null, duration: Number(formData.get("duration")) || null, fullScore: Number(formData.get("fullScore")) || null, sourceUrl: f("sourceUrl") || null, summary: f("summary") || null, questions: JSON.stringify(JSON.parse(questions)), seq, }, }); revalidatePath("/parent/exams"); revalidatePath("/exam"); return { ok: true }; } export async function deleteExamPaper(paperId: string): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return; await db.delete(examPapers).where(eq(examPapers.paperId, paperId)); revalidatePath("/parent/exams"); revalidatePath("/exam"); } ``` - [ ] **Step 3: 类型检查** Run: `npx tsc --noEmit` Expected: 无输出 - [ ] **Step 4: 提交** ```bash git add src/app/parent/actions.ts git commit -m "家长端新增成绩与试卷管理 action:周测/考试记录改删+试卷保存删除(归属性校验+JSON校验)" ``` --- ### Task 2: 周测成绩管理页 **Files:** - Create: `src/app/parent/weekly-records/page.tsx`(server) - Create: `src/app/parent/weekly-records/weekly-list.tsx`(client,行内编辑) - Modify: `src/app/parent/dashboard.tsx`(移除 `WeeklyRow` 类型与 `weekly` prop 及只读周测块,替换为「每周检测成绩管理」入口卡片) - Modify: `src/app/parent/page.tsx`(删除 weeklyRows 查询、weekly 组装、`WeeklyRow` 类型导入与传参) **Interfaces:** - Consumes: Task 1 的 `updateWeeklyRecord`/`deleteWeeklyRecord`(签名见 Task 1)。 - Produces: `/parent/weekly-records` 路由。 - [ ] **Step 1: 创建 `src/app/parent/weekly-records/page.tsx`** ```tsx import Link from "next/link"; import { redirect } from "next/navigation"; import { desc, eq } from "drizzle-orm"; import { BackgroundBlobs } from "@/components/background-blobs"; import { db } from "@/db"; import { users, weeklyTest } from "@/db/schema"; import { getSessionUser } from "@/lib/auth"; import { WeeklyList } from "./weekly-list"; // xuchao: 周测成绩管理——编辑分数/达标、删除误生成记录(不联动积分打卡) export default async function WeeklyRecordsPage() { const parent = await getSessionUser(); if (!parent) redirect("/login"); if (parent.role !== "parent") redirect("/kid"); const rows = await db .select({ id: weeklyTest.id, childName: users.displayName, weekKey: weeklyTest.weekKey, score: weeklyTest.score, passed: weeklyTest.passed, takenAt: weeklyTest.takenAt, }) .from(weeklyTest) .innerJoin(users, eq(users.id, weeklyTest.childUserId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(weeklyTest.takenAt)) .limit(100); return (
← 返回家长中心

每周检测成绩管理

最近 {rows.length} 条

修改分数/达标状态或删除记录不会影响已发的积分与当日打卡。

({ id: r.id, childName: r.childName, weekKey: r.weekKey, score: r.score ?? 0, passed: r.passed, takenAt: r.takenAt.toLocaleDateString("zh-CN"), }))} />
); } ``` - [ ] **Step 2: 创建 `src/app/parent/weekly-records/weekly-list.tsx`** ```tsx "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, Pencil, RotateCcw, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { deleteWeeklyRecord, updateWeeklyRecord } from "../actions"; export type WeeklyRowItem = { id: number; childName: string; weekKey: string; score: number; passed: boolean; takenAt: string; }; // xuchao: 周测成绩行内编辑(分数+达标开关)、删除;保存后刷新服务端列表 export function WeeklyList({ rows }: { rows: WeeklyRowItem[] }) { const router = useRouter(); const [editingId, setEditingId] = useState(null); const [score, setScore] = useState(""); const [passed, setPassed] = useState(false); const [msg, setMsg] = useState(""); const [pending, startTransition] = useTransition(); const startEdit = (r: WeeklyRowItem) => { setEditingId(r.id); setScore(String(r.score)); setPassed(r.passed); setMsg(""); }; const save = () => { if (editingId == null) return; const s = Number(score); const err = (t: string) => setMsg(t); startTransition(async () => { const r = await updateWeeklyRecord(editingId, s, passed); if (r) err(r); else { setEditingId(null); router.refresh(); } }); }; const remove = (id: number, label: string) => { if (!confirm(`确定删除「${label}」的这条周测记录?孩子可重新检测。`)) return; startTransition(() => { deleteWeeklyRecord(id).then(() => router.refresh()); }); }; return (
{rows.length === 0 && (

暂无周测记录

)} {rows.map((r) => (
{editingId === r.id ? (

{r.childName} · {r.weekKey}

分数 setScore(e.target.value)} />
{msg && {msg}}
) : (
{r.childName} {r.weekKey} {r.score} 分 {r.passed ? "✅" : "❌"} {r.takenAt}
)}
))}
); } ``` - [ ] **Step 3: dashboard.tsx 移除只读周测块与 weekly prop** a) 删除类型 `export type WeeklyRow = {...};` 定义。 b) `ParentDashboard` 签名:参数删 `weekly`(三个位置:解构、props 类型注释、调用处)。 c) 课程管理 grid 中「📝 每周检测成绩」只读 `
` 块整体替换为: ```tsx

📝 每周检测成绩管理

改分数/达标,删除误生成记录

``` - [ ] **Step 4: page.tsx 删除 weekly 查询与传参** a) `WeeklyRow` 从 dashboard 类型导入中移除;`weekly` prop 从 `ParentDashboard` 调用中移除。 b) 删除「周测成绩(课程管理)」块:weeklyRows 查询(约 182-200 行)、`const weekly: WeeklyRow[] = ...` 组装、以及 `WeeklyRow` 类型导入与 `` 传参。 ⚠️ 注意:`weeklyTest` 的 schema import **必须保留**——page.tsx 顶部 overview 日历的 wRows 查询(约 74 行)仍在使用它。只删「周测成绩(课程管理)」块。 - [ ] **Step 5: 类型检查** Run: `npx tsc --noEmit` Expected: 无输出 - [ ] **Step 6: 提交** ```bash git add src/app/parent/weekly-records src/app/parent/dashboard.tsx src/app/parent/page.tsx git commit -m "家长端每周检测成绩管理页:/parent/weekly-records 编辑分数达标+删除,替换课程管理只读块" ``` --- ### Task 3: 考试成绩记录管理页 **Files:** - Create: `src/app/parent/exam-records/page.tsx`(server) - Create: `src/app/parent/exam-records/exam-list.tsx`(client,行内编辑,结构同 weekly-list) - Modify: `src/app/parent/dashboard.tsx`(课程管理 grid 加「考试记录管理」入口卡片) **Interfaces:** - Consumes: Task 1 的 `updateExamRecord`/`deleteExamRecord`。 - Produces: `/parent/exam-records` 路由。 - [ ] **Step 1: 创建 `src/app/parent/exam-records/page.tsx`** ```tsx import Link from "next/link"; import { redirect } from "next/navigation"; import { desc, eq } from "drizzle-orm"; import { BackgroundBlobs } from "@/components/background-blobs"; import { db } from "@/db"; import { examPapers, examRecords, users } from "@/db/schema"; import { getSessionUser } from "@/lib/auth"; import { ExamList } from "./exam-list"; // xuchao: 考试成绩记录管理——编辑分数/达标、删除记录(卷已删时标题显示占位) export default async function ExamRecordsPage() { const parent = await getSessionUser(); if (!parent) redirect("/login"); if (parent.role !== "parent") redirect("/kid"); const rows = await db .select({ id: examRecords.id, childName: users.displayName, paperTitle: examPapers.title, score: examRecords.score, passed: examRecords.passed, takenAt: examRecords.takenAt, }) .from(examRecords) .innerJoin(users, eq(users.id, examRecords.childUserId)) .leftJoin(examPapers, eq(examPapers.paperId, examRecords.paperId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(examRecords.takenAt)) .limit(100); return (
← 返回家长中心

考试记录管理

最近 {rows.length} 条

修改分数/达标状态或删除记录不会影响已发的积分与当日打卡;删除后孩子可重新考试。

({ id: r.id, childName: r.childName, paperTitle: r.paperTitle ?? "已删试卷", score: r.score ?? 0, passed: r.passed, takenAt: r.takenAt.toLocaleDateString("zh-CN"), }))} />
); } ``` - [ ] **Step 2: 创建 `src/app/parent/exam-records/exam-list.tsx`** ```tsx "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, Pencil, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { deleteExamRecord, updateExamRecord } from "../actions"; export type ExamRowItem = { id: number; childName: string; paperTitle: string; score: number; passed: boolean; takenAt: string; }; // xuchao: 考试记录行内编辑(分数+达标开关)、删除;保存后刷新服务端列表 export function ExamList({ rows }: { rows: ExamRowItem[] }) { const router = useRouter(); const [editingId, setEditingId] = useState(null); const [score, setScore] = useState(""); const [passed, setPassed] = useState(false); const [msg, setMsg] = useState(""); const [pending, startTransition] = useTransition(); const startEdit = (r: ExamRowItem) => { setEditingId(r.id); setScore(String(r.score)); setPassed(r.passed); setMsg(""); }; const save = () => { if (editingId == null) return; const s = Number(score); startTransition(async () => { const r = await updateExamRecord(editingId, s, passed); if (r) setMsg(r); else { setEditingId(null); router.refresh(); } }); }; const remove = (id: number, label: string) => { if (!confirm(`确定删除「${label}」这条考试记录?孩子可重新考试。`)) return; startTransition(() => { deleteExamRecord(id).then(() => router.refresh()); }); }; return (
{rows.length === 0 && (

暂无考试记录

)} {rows.map((r) => (
{editingId === r.id ? (

{r.childName} · {r.paperTitle}

分数 setScore(e.target.value)} />
{msg && {msg}}
) : (
{r.childName} {r.paperTitle} {r.score} 分 {r.passed ? "✅" : "❌"} {r.takenAt}
)}
))}
); } ``` - [ ] **Step 3: dashboard.tsx 加「考试记录管理」入口卡片** 课程管理 grid 中,「📝 每周检测成绩管理」卡片之后插入: ```tsx

📄 考试记录管理

改考试成绩/达标,删除误生成记录

``` - [ ] **Step 4: 类型检查** Run: `npx tsc --noEmit` Expected: 无输出 - [ ] **Step 5: 提交** ```bash git add src/app/parent/exam-records src/app/parent/dashboard.tsx git commit -m "家长端考试记录管理页:/parent/exam-records 编辑分数达标+删除(卷已删兼容)" ``` --- ### Task 4: 考试试卷管理页(列表/新建/编辑) **Files:** - Create: `src/app/parent/exams/page.tsx`(server,月份分组列表) - Create: `src/app/parent/exams/paper-form.tsx`(client,元信息表单 + 题目 JSON 文本域 + 保存/删除) - Create: `src/app/parent/exams/new/page.tsx`(server,空表单) - Create: `src/app/parent/exams/[paperId]/page.tsx`(server,编辑表单) - Modify: `src/app/parent/dashboard.tsx`(课程管理 grid 加「考试试卷管理」入口卡片) **Interfaces:** - Consumes: Task 1 的 `saveExamPaper`/`deleteExamPaper`;`lib/exam.ts` 的 `getPaper`、`EXAM_TYPE_LABEL`、`listPapers`。 - Produces: `/parent/exams`、`/parent/exams/new`、`/parent/exams/[paperId]` 路由。 - [ ] **Step 1: 创建 `src/app/parent/exams/page.tsx`** ```tsx import Link from "next/link"; import { Plus } from "lucide-react"; import { redirect } from "next/navigation"; import { BackgroundBlobs } from "@/components/background-blobs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { getSessionUser } from "@/lib/auth"; import { EXAM_TYPE_LABEL, listPapers } from "@/lib/exam"; // xuchao: 试卷管理列表——与孩子端考试中心同分组(月份),点击进入编辑 const MONTH_LABEL: Record = { 0: "入学 · 分班考", 9: "9 月 · 有理数", 10: "10 月 · 整式的加减", 11: "11 月 · 一元一次方程", 12: "12 月 · 几何图形初步", 1: "1 月 · 期末全册", }; const MONTH_ORDER = [0, 9, 10, 11, 12, 1]; function countQuestions(qs: string): number { try { const arr = JSON.parse(qs) as unknown[]; return Array.isArray(arr) ? arr.length : 0; } catch { return 0; } } export default async function ExamsAdmin() { const parent = await getSessionUser(); if (!parent) redirect("/login"); if (parent.role !== "parent") redirect("/kid"); const papers = await listPapers("g7", "s1"); return (
← 返回家长中心

考试试卷管理

点击试卷可编辑元信息与题目,保存后孩子端考试中心立即生效。

{papers.length === 0 && (

还没有试卷,点右上角新建

)} {MONTH_ORDER.map((m) => { const group = papers.filter((p) => p.month === m); if (group.length === 0) return null; return (

{MONTH_LABEL[m]}

{group.map((p) => ( {p.title} {EXAM_TYPE_LABEL[p.examType] ?? p.examType} {p.seq} 号卷 ))}
); })}
); } ``` - [ ] **Step 2: 创建 `src/app/parent/exams/paper-form.tsx`** ```tsx "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2, Save, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { EXAM_TYPE_LABEL } from "@/lib/exam"; import { deleteExamPaper, saveExamPaper } from "../actions"; export type PaperMeta = { paperId: string; title: string; examType: string; region: string | null; year: number | null; duration: number | null; fullScore: number | null; month: number; seq: number; sourceUrl: string | null; summary: string | null; questions: string; }; // xuchao: 试卷编辑器——元信息表单 + 题目 JSON 文本域;保存走服务端完整校验 export function PaperForm({ paper, isNew }: { paper: PaperMeta | null; isNew: boolean }) { const router = useRouter(); const [f, setF] = useState({ paperId: paper?.paperId ?? "", title: paper?.title ?? "", examType: paper?.examType ?? "monthly", region: paper?.region ?? "", year: paper?.year != null ? String(paper.year) : "", duration: paper?.duration != null ? String(paper.duration) : "", fullScore: paper?.fullScore != null ? String(paper.fullScore) : "100", month: String(paper?.month ?? 9), seq: String(paper?.seq ?? 1), sourceUrl: paper?.sourceUrl ?? "", summary: paper?.summary ?? "", questions: paper ? JSON.stringify(JSON.parse(paper.questions), null, 2) : "[]", }); const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null); const [pending, startTransition] = useTransition(); // xuchao: 客户端轻量预览:JSON 可解析则显示题数,失败显示错误(完整校验在服务端) const preview = (() => { try { const arr = JSON.parse(f.questions) as unknown[]; if (!Array.isArray(arr)) return { ok: false, text: "题目必须是 JSON 数组" }; return { ok: true, text: `共 ${arr.length} 道题` }; } catch (e) { return { ok: false, text: `JSON 语法错误:${(e as Error).message}` }; } })(); const save = () => { setMsg(null); const fd = new FormData(); (Object.keys(f) as (keyof typeof f)[]).forEach((k) => fd.set(k, f[k])); startTransition(() => { saveExamPaper(fd).then((r) => { if (r.error) setMsg({ text: r.error }); else setMsg({ ok: true, text: "已保存,孩子端立即生效" }); }); }); }; const remove = () => { if (!paper || !confirm(`确定删除试卷「${paper.title}」?孩子的历史作答记录会保留。`)) return; startTransition(() => { deleteExamPaper(paper.paperId).then(() => router.push("/parent/exams")); }); }; const n = (key: keyof typeof f) => ({ value: f[key], onChange: (e: React.ChangeEvent) => setF((prev) => ({ ...prev, [key]: e.target.value })), }); return (
{preview.text}