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 { POINT_EVENT_LABELS, listPointRules, pointHistory, totalPoints as childPointsTotal, } from "@/lib/points"; import { viewUrl } from "@/lib/oss"; import { ParentDashboard, type DevicesData, type OverviewData, type PointsData, 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, gtestRows, recMonth] = 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), db .select({ completedAt: grammarProgress.completedAt, score: grammarProgress.score, passed: grammarProgress.passed, title: grammarPoints.title, }) .from(grammarProgress) .innerJoin(grammarPoints, eq(grammarPoints.pointId, grammarProgress.pointId)) .where(and(eq(grammarProgress.childUserId, kid.id), gte(grammarProgress.completedAt, new Date(`${mStart}T00:00:00`)))), db .select({ word: wordRecording.word, ossKey: wordRecording.ossKey, createdAt: wordRecording.createdAt }) .from(wordRecording) .where(and(eq(wordRecording.childUserId, kid.id), gte(wordRecording.createdAt, new Date(`${mStart}T00:00:00`)))), ]); 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 })), recs: recMonth .filter((r) => dstr(r.createdAt) === d) .map((r) => ({ word: r.word, url: viewUrl(r.ossKey) })), gtests: gtestRows .filter((r) => r.completedAt && dstr(r.completedAt) === d) .map((r) => ({ title: r.title, score: r.score ?? 0, passed: r.passed })), }; } 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, points: t.points, 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 rules = await listPointRules(); const pointsData: PointsData = { rules, kids: [] }; const eventLabel = (t: string) => (t === "checkin" ? "打卡" : POINT_EVENT_LABELS[t] ?? t); for (const kid of kids) { const [total, recent] = await Promise.all([childPointsTotal(kid.id), pointHistory(kid.id, 5)]); pointsData.kids.push({ id: kid.id, name: kid.name, total, recent: recent.map((r) => ({ id: r.id, label: eventLabel(r.eventType), remark: r.remark, points: r.points, date: r.createdAt.toLocaleDateString("zh-CN", SH), })), }); } return ( ); }