家长端新增成绩与试卷管理 action:周测/考试记录改删+试卷保存删除(归属性校验+JSON校验)
This commit is contained in:
+166
-1
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user