From 859fdcbda7f7b0ff5d81845f42db1d24b768a537 Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Wed, 19 Aug 2026 16:47:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=B6=E9=95=BF=E7=AB=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E4=B8=8E=E8=AF=95=E5=8D=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20action:=E5=91=A8=E6=B5=8B/=E8=80=83=E8=AF=95=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=94=B9=E5=88=A0+=E8=AF=95=E5=8D=B7=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=88=A0=E9=99=A4(=E5=BD=92=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C+JSON=E6=A0=A1=E9=AA=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/parent/actions.ts | 167 +++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/src/app/parent/actions.ts b/src/app/parent/actions.ts index 9892874..56cb056 100644 --- a/src/app/parent/actions.ts +++ b/src/app/parent/actions.ts @@ -5,7 +5,7 @@ import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; -import { activationCodes, familyInvites, sessions, taskInstances, taskTemplates, users } from "@/db/schema"; +import { activationCodes, examPapers, examRecords, familyInvites, sessions, taskInstances, taskTemplates, users, weeklyTest } from "@/db/schema"; import { SUBJECT_VALUES } from "@/db/schema"; import { todayStr } from "@/lib/dates"; import { ensureDay } from "@/lib/tasks"; @@ -258,3 +258,168 @@ export async function removeChild(childId: number): Promise { revalidatePath("/parent"); revalidatePath("/learn"); } + +// ===== 成绩与试卷管理:周测记录(不联动积分/打卡) ===== +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"); +}