test / test (push) Successful in 37s
uploadPaperFigure 存 public/exam/<卷>/<uuid>.ext;编辑器预览/移除; quiz-block 渲染 q.figure;compose 挂 exam-figures 卷防重建丢失。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
"use server";
|
|
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import { db } from "@/db";
|
|
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";
|
|
import { getSessionUser } from "@/lib/auth";
|
|
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
|
import { validateQuestions } from "@/lib/question-validation";
|
|
|
|
function hashCode(code: string) {
|
|
return crypto.createHash("sha256").update(code).digest("hex");
|
|
}
|
|
|
|
export type CreateCodeState = {
|
|
code?: string;
|
|
childName?: string;
|
|
error?: string;
|
|
} | null;
|
|
|
|
// 家长为指定孩子生成一次性激活码(15 分钟有效)
|
|
export async function createActivationCode(
|
|
_prev: CreateCodeState,
|
|
formData: FormData,
|
|
): Promise<CreateCodeState> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
|
|
|
const childId = Number(formData.get("childId"));
|
|
const [child] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, childId))
|
|
.limit(1);
|
|
if (!child || child.role !== "child" || child.familyId !== parent.familyId) {
|
|
return { error: "孩子不存在" };
|
|
}
|
|
|
|
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
|
await db.insert(activationCodes).values({
|
|
childUserId: child.id,
|
|
codeHash: hashCode(code),
|
|
createdBy: parent.id,
|
|
expiresAt: new Date(Date.now() + 15 * 60_000),
|
|
});
|
|
|
|
return { code, childName: child.displayName };
|
|
}
|
|
|
|
// xuchao: 生成家庭邀请码(6 位,15 分钟一次性),对方凭码在 /register 注册加入
|
|
export async function generateFamilyInvite(): Promise<{ code?: string; error?: string }> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
|
|
|
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
|
await db.insert(familyInvites).values({
|
|
familyId: parent.familyId,
|
|
codeHash: hashCode(code),
|
|
createdBy: parent.id,
|
|
expiresAt: new Date(Date.now() + 15 * 60_000),
|
|
});
|
|
return { code };
|
|
}
|
|
|
|
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
|
|
export async function makeupTask(instanceId: number, done: boolean) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
|
|
const [row] = await db
|
|
.select({ date: taskInstances.date, familyId: users.familyId })
|
|
.from(taskInstances)
|
|
.innerJoin(users, eq(users.id, taskInstances.childUserId))
|
|
.where(eq(taskInstances.id, instanceId))
|
|
.limit(1);
|
|
|
|
if (!row || row.familyId !== parent.familyId) throw new Error("任务不存在");
|
|
if (row.date >= todayStr()) throw new Error("只能对过去的日期补卡");
|
|
|
|
await db
|
|
.update(taskInstances)
|
|
.set({ status: done ? "done" : "pending", isMakeup: done, doneAt: done ? new Date() : null })
|
|
.where(eq(taskInstances.id, instanceId));
|
|
|
|
revalidatePath("/parent");
|
|
}
|
|
|
|
// ===== 周模板管理 =====
|
|
|
|
const templateSchema = z.object({
|
|
childId: z.coerce.number().int().positive(),
|
|
dow: z.coerce.number().int().min(0).max(6).catch(0),
|
|
subject: z.enum(SUBJECT_VALUES).catch("other"),
|
|
mode: z.enum(["weekly", "date", "range", "week"]).catch("weekly"),
|
|
dateStart: z.string().nullish(),
|
|
dateEnd: z.string().nullish(),
|
|
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
|
|
requiresPhoto: z.boolean(),
|
|
// xuchao: 该任务完成可得积分,0~9999
|
|
points: z.coerce.number().int().min(0).max(9999).catch(10),
|
|
});
|
|
|
|
async function requireOwnerOfChild(childId: number) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
const [child] = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child")))
|
|
.limit(1);
|
|
if (!child) throw new Error("孩子不存在");
|
|
return parent;
|
|
}
|
|
|
|
// 添加模板任务(次日及以后生效,不改动已有历史)
|
|
export async function addTemplate(formData: FormData) {
|
|
const parsed = templateSchema.safeParse({
|
|
childId: formData.get("childId"),
|
|
dow: formData.get("dow"),
|
|
subject: formData.get("subject"),
|
|
mode: formData.get("mode"),
|
|
dateStart: formData.get("dateStart") || null,
|
|
dateEnd: formData.get("dateEnd") || null,
|
|
title: formData.get("title"),
|
|
requiresPhoto: formData.get("requiresPhoto") === "on",
|
|
points: formData.get("points") || 10,
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
await requireOwnerOfChild(parsed.data.childId);
|
|
|
|
const [maxRow] = await db
|
|
.select({ maxSort: sql<number>`COALESCE(MAX(${taskTemplates.sortOrder}), -1)` })
|
|
.from(taskTemplates)
|
|
.where(eq(taskTemplates.childUserId, parsed.data.childId));
|
|
|
|
await db.insert(taskTemplates).values({
|
|
childUserId: parsed.data.childId,
|
|
dow: parsed.data.dow,
|
|
subject: parsed.data.subject,
|
|
mode: parsed.data.mode,
|
|
dateStart: parsed.data.dateStart || null,
|
|
dateEnd: parsed.data.dateEnd || null,
|
|
title: parsed.data.title,
|
|
requiresPhoto: parsed.data.requiresPhoto,
|
|
points: parsed.data.points,
|
|
sortOrder: maxRow.maxSort + 1,
|
|
});
|
|
await ensureDay(parsed.data.childId, todayStr());
|
|
revalidatePath("/parent");
|
|
}
|
|
|
|
// xuchao: 修改模板任务分值;当天未完成的快照同步更新,设置立即生效
|
|
export async function updateTemplatePoints(templateId: number, points: number) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
const p = Math.max(0, Math.min(9999, Math.round(points)));
|
|
const [row] = await db
|
|
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
|
|
.from(taskTemplates)
|
|
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
|
|
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
|
|
.limit(1);
|
|
if (!row) throw new Error("任务不存在");
|
|
await db.update(taskTemplates).set({ points: p }).where(eq(taskTemplates.id, templateId));
|
|
await db
|
|
.update(taskInstances)
|
|
.set({ points: p })
|
|
.where(
|
|
and(
|
|
eq(taskInstances.sourceTemplateId, templateId),
|
|
eq(taskInstances.date, todayStr()),
|
|
eq(taskInstances.status, "pending"),
|
|
),
|
|
);
|
|
revalidatePath("/parent");
|
|
revalidatePath("/kid");
|
|
}
|
|
|
|
// xuchao: 保存测试类积分规则(只接受已知事件,分值 0~9999)
|
|
export async function savePointRules(values: { eventType: string; points: number }[]) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
for (const v of values) {
|
|
if (!(v.eventType in DEFAULT_RULE_POINTS)) continue;
|
|
await upsertPointRule(v.eventType, Math.max(0, Math.min(9999, Math.round(v.points))));
|
|
}
|
|
revalidatePath("/parent");
|
|
}
|
|
|
|
// 删除模板任务:删除后立即对账当天,孩子端未完成的对应任务会同步移除
|
|
export async function deleteTemplate(templateId: number) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
const [row] = await db
|
|
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
|
|
.from(taskTemplates)
|
|
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
|
|
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
|
|
.limit(1);
|
|
if (!row) throw new Error("任务不存在");
|
|
await db.delete(taskTemplates).where(eq(taskTemplates.id, templateId));
|
|
await ensureDay(row.childUserId, todayStr());
|
|
revalidatePath("/parent");
|
|
}
|
|
|
|
// ===== 设备管理 =====
|
|
|
|
// 注销某台设备的会话(孩子那台 iPad 下次打开需要重新激活)
|
|
export async function revokeSession(sessionId: number) {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
|
const [row] = await db
|
|
.select({ id: sessions.id })
|
|
.from(sessions)
|
|
.innerJoin(users, eq(users.id, sessions.userId))
|
|
.where(and(eq(sessions.id, sessionId), eq(users.familyId, parent.familyId)))
|
|
.limit(1);
|
|
if (!row) throw new Error("会话不存在");
|
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
revalidatePath("/parent");
|
|
}
|
|
|
|
// ===== 孩子管理:增/删 =====
|
|
export async function addChild(formData: FormData): Promise<string | null> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
|
|
const name = String(formData.get("name") ?? "").trim();
|
|
if (!name) return "请填写孩子称呼";
|
|
const [mx] = await db
|
|
.select({ m: sql<number>`COALESCE(MAX(${users.sortOrder}), -1)` })
|
|
.from(users)
|
|
.where(eq(users.familyId, parent.familyId));
|
|
await db.insert(users).values({
|
|
familyId: parent.familyId,
|
|
role: "child",
|
|
displayName: name,
|
|
sortOrder: mx.m + 1,
|
|
});
|
|
revalidatePath("/parent");
|
|
revalidatePath("/learn");
|
|
return null;
|
|
}
|
|
|
|
export async function removeChild(childId: number): Promise<void> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return;
|
|
const [row] = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child")))
|
|
.limit(1);
|
|
if (!row) return;
|
|
await db.delete(users).where(eq(users.id, childId));
|
|
revalidatePath("/parent");
|
|
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: 保存试卷(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 = validateQuestions(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");
|
|
}
|
|
|
|
// xuchao: 上传试卷题目配图(几何体等)——存 public/exam/<paperId>/<uuid>.<ext>,返回文件名写回 q.figure
|
|
// 生产容器 public/exam 挂卷持久化(compose volumes: exam-figures),重建不丢
|
|
export async function uploadPaperFigure(
|
|
paperId: string,
|
|
file: File,
|
|
): Promise<{ filename?: string; error?: string }> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
|
if (!/^[a-z0-9-]+$/.test(paperId)) return { error: "试卷编号不合法" };
|
|
if (!file.type.startsWith("image/")) return { error: "只能上传图片文件" };
|
|
const ext = file.name.toLowerCase().split(".").pop();
|
|
if (!["png", "jpg", "jpeg", "webp"].includes(ext ?? "")) return { error: "只支持 png/jpg/webp 图片" };
|
|
if (file.size > 8 * 1024 * 1024) return { error: "图片不能超过 8MB" };
|
|
|
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
const dir = path.join(process.cwd(), "public", "exam", paperId);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, filename), buf);
|
|
return { filename };
|
|
}
|