import crypto from "node:crypto"; import { and, asc, desc, eq, gte, lte, inArray } from "drizzle-orm"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { db } from "@/db"; import { sessions, taskInstances, taskTemplates, users, grammarProgress, grammarPoints, weeklyTest, wrongBook, wordRecording, } from "@/db/schema"; import { SESSION_COOKIE, getSessionUser } from "@/lib/auth"; import { addDays, dowOf, todayStr } from "@/lib/dates"; import { passedCount, totalPoints } from "@/lib/grammar"; import { viewUrl } from "@/lib/oss"; import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData, type WeeklyRow, } from "./dashboard"; const SH = { timeZone: "Asia/Shanghai" } as const; const dstr = (d: Date) => d.toLocaleDateString("en-CA", SH); export default async function ParentPage() { const parent = await getSessionUser(); if (!parent) redirect("/login"); if (parent.role !== "parent") redirect("/kid"); const kids = await db .select({ id: users.id, name: users.displayName }) .from(users) .where(and(eq(users.familyId, parent.familyId), eq(users.role, "child"))) .orderBy(asc(users.sortOrder)); const today = todayStr(); const ym = today.slice(0, 7); const daysInMonth = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0).getDate(); const mStart = `${ym}-01`; const mEnd = `${ym}-${String(daysInMonth).padStart(2, "0")}`; const gTotal = await totalPoints(); // ===== 学习概览(日历) ===== const overview: OverviewData = { kids: [] }; for (const kid of kids) { const [instRows, gRows, wRows, wrongRow, gDone] = await Promise.all([ db .select({ date: taskInstances.date, status: taskInstances.status, title: taskInstances.title, subject: taskInstances.subject, }) .from(taskInstances) .where(and(eq(taskInstances.childUserId, kid.id), gte(taskInstances.date, mStart), lte(taskInstances.date, mEnd))), db .select({ completedAt: grammarProgress.completedAt, passed: grammarProgress.passed }) .from(grammarProgress) .where(and(eq(grammarProgress.childUserId, kid.id), eq(grammarProgress.passed, true))), db.select().from(weeklyTest).where(eq(weeklyTest.childUserId, kid.id)).orderBy(desc(weeklyTest.takenAt)), db .select({ id: wrongBook.id }) .from(wrongBook) .where(and(eq(wrongBook.childUserId, kid.id), eq(wrongBook.mastered, false))), passedCount(kid.id), ]); const days: OverviewData["kids"][number]["days"] = {}; for (let i = 1; i <= daysInMonth; i++) { const d = `${ym}-${String(i).padStart(2, "0")}`; const inst = instRows.filter((r) => r.date === d); const grammar = gRows.filter((r) => dstr(r.completedAt) === d).length; const w = wRows.find((r) => dstr(r.takenAt) === d); days[d] = { done: inst.filter((r) => r.status === "done").length, total: inst.length, grammar, weekly: w ? w.score : null, tasks: inst.map((r) => ({ title: r.title, status: r.status, subject: r.subject })), }; } overview.kids.push({ id: kid.id, name: kid.name, grammar: { done: gDone, total: gTotal }, wrongOpen: wrongRow.length, weeklyLatest: wRows[0] ? { weekKey: wRows[0].weekKey, score: wRows[0].score ?? 0, passed: wRows[0].passed } : null, days, }); } // ===== 任务模板 ===== const templateRows = kids.length ? await db .select() .from(taskTemplates) .where(inArray(taskTemplates.childUserId, kids.map((k) => k.id))) .orderBy(asc(taskTemplates.childUserId), asc(taskTemplates.sortOrder)) : []; const templates: TemplatesData = { kids: kids.map((kid) => ({ id: kid.id, name: kid.name, items: templateRows .filter((t) => t.childUserId === kid.id) .map((t) => ({ id: t.id, dow: t.dow, title: t.title, subject: t.subject, requiresPhoto: t.requiresPhoto, mode: t.mode, dateStart: t.dateStart, dateEnd: t.dateEnd, })), })), }; // ===== 设备会话 ===== const token = (await cookies()).get(SESSION_COOKIE)?.value; const tokenHash = token ? crypto.createHash("sha256").update(token).digest("hex") : null; const sessionRows = await db .select({ id: sessions.id, tokenHash: sessions.tokenHash, deviceLabel: sessions.deviceLabel, createdAt: sessions.createdAt, expiresAt: sessions.expiresAt, owner: users.displayName, role: users.role, }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(sessions.createdAt)); const devices: DevicesData = { rows: sessionRows.map((s) => ({ id: s.id, owner: s.role === "parent" ? `${s.owner}(家长)` : s.owner, deviceLabel: s.deviceLabel, createdAt: dstr(s.createdAt), expiresAt: dstr(s.expiresAt), isCurrent: s.tokenHash === tokenHash, })), }; // ===== 周测成绩(课程管理) ===== const weeklyRows = await db .select({ childName: users.displayName, weekKey: weeklyTest.weekKey, score: weeklyTest.score, passed: weeklyTest.passed, }) .from(weeklyTest) .innerJoin(users, eq(users.id, weeklyTest.childUserId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(weeklyTest.takenAt)) .limit(10); const weekly: WeeklyRow[] = weeklyRows.map((w) => ({ childName: w.childName, weekKey: w.weekKey, score: w.score ?? 0, passed: w.passed, })); // ===== 单词跟读录音(课程管理回放) ===== const recRows = await db .select({ childName: users.displayName, word: wordRecording.word, ossKey: wordRecording.ossKey, createdAt: wordRecording.createdAt, }) .from(wordRecording) .innerJoin(users, eq(users.id, wordRecording.childUserId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(wordRecording.createdAt)) .limit(20); const recordings = recRows.map((r) => ({ childName: r.childName, word: r.word, url: viewUrl(r.ossKey), at: dstr(r.createdAt), })); // ===== 英语语法检测明细 ===== const grammarRows = await db .select({ childName: users.displayName, title: grammarPoints.title, score: grammarProgress.score, passed: grammarProgress.passed, completedAt: grammarProgress.completedAt, }) .from(grammarProgress) .innerJoin(users, eq(users.id, grammarProgress.childUserId)) .innerJoin(grammarPoints, eq(grammarPoints.pointId, grammarProgress.pointId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(grammarProgress.completedAt)) .limit(20); const grammarTests = grammarRows.map((g) => ({ childName: g.childName, title: g.title, score: g.score ?? 0, passed: g.passed, at: g.completedAt ? dstr(g.completedAt) : "", })); // ===== 孩子错题数据 ===== const wrongRows = await db .select({ childName: users.displayName, question: wrongBook.question, userAnswer: wrongBook.userAnswer, correctAnswer: wrongBook.correctAnswer, sourceTitle: wrongBook.sourceTitle, mastered: wrongBook.mastered, }) .from(wrongBook) .innerJoin(users, eq(users.id, wrongBook.childUserId)) .where(eq(users.familyId, parent.familyId)) .orderBy(desc(wrongBook.createdAt)) .limit(30); const wrongItems = wrongRows.map((w) => { let stem = ""; try { stem = (JSON.parse(w.question) as { stem?: string }).stem ?? ""; } catch { stem = ""; } return { childName: w.childName, stem, userAnswer: w.userAnswer ?? "", correctAnswer: w.correctAnswer ?? "", sourceTitle: w.sourceTitle ?? "", mastered: w.mastered, }; }); return ( ); }