diff --git a/docs/superpowers/plans/2026-08-19-parent-exam-manage.md b/docs/superpowers/plans/2026-08-19-parent-exam-manage.md new file mode 100644 index 0000000..f425e5f --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-parent-exam-manage.md @@ -0,0 +1,1114 @@ +# 家长端成绩与试卷管理 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} +
+