家长端新增成绩与试卷管理 action:周测/考试记录改删+试卷保存删除(归属性校验+JSON校验)

This commit is contained in:
jacobxu666
2026-08-19 16:47:06 +08:00
parent 36ecc71139
commit 859fdcbda7
+166 -1
View File
@@ -5,7 +5,7 @@ import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; 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 { SUBJECT_VALUES } from "@/db/schema";
import { todayStr } from "@/lib/dates"; import { todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks"; import { ensureDay } from "@/lib/tasks";
@@ -258,3 +258,168 @@ export async function removeChild(childId: number): Promise<void> {
revalidatePath("/parent"); revalidatePath("/parent");
revalidatePath("/learn"); revalidatePath("/learn");
} }
// ===== 成绩与试卷管理:周测记录(不联动积分/打卡) =====
export async function updateWeeklyRecord(id: number, score: number, passed: boolean): Promise<string | null> {
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<void> {
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<string | null> {
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<void> {
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<string, unknown>;
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<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
await db.delete(examPapers).where(eq(examPapers.paperId, paperId));
revalidatePath("/parent/exams");
revalidatePath("/exam");
}