- 家长手机号登录 + 孩子设备一次性激活(长期会话) - 周模板自动生成每日任务快照,支持拍照凭证直传对象存储 - 连击计算(空白日自动达成)、家长补卡带标记 - 家长端三标签:今日概览 / 任务模板 / 设备管理 - Docker 独立输出,经 Traefik 接入 jump.hn12301.net Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
|
import { cookies } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import { db } from "@/db";
|
|
import { sessions, taskInstances, taskTemplates, users } from "@/db/schema";
|
|
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
|
import { daysAgo, todayStr } from "@/lib/dates";
|
|
import { ensureDay } from "@/lib/tasks";
|
|
import { streakOf } from "@/lib/streak";
|
|
import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard";
|
|
|
|
const SHANGHAI = { timeZone: "Asia/Shanghai" } as const;
|
|
const fmtDateTime = (d: Date) =>
|
|
d.toLocaleString("zh-CN", { ...SHANGHAI, hour12: false }).replace(/\//g, "-");
|
|
|
|
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 weekDates = Array.from({ length: 7 }, (_, i) => daysAgo(i)); // 今天在前
|
|
|
|
// ===== 今日概览 =====
|
|
const overview: OverviewData = { kids: [] };
|
|
for (const kid of kids) {
|
|
await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照
|
|
const [streak, rows] = await Promise.all([
|
|
streakOf(kid.id),
|
|
db
|
|
.select({
|
|
id: taskInstances.id,
|
|
date: taskInstances.date,
|
|
title: taskInstances.title,
|
|
status: taskInstances.status,
|
|
isMakeup: taskInstances.isMakeup,
|
|
sortOrder: taskInstances.sortOrder,
|
|
})
|
|
.from(taskInstances)
|
|
.where(and(eq(taskInstances.childUserId, kid.id), inArray(taskInstances.date, weekDates)))
|
|
.orderBy(desc(taskInstances.date), asc(taskInstances.sortOrder)),
|
|
]);
|
|
|
|
const week = weekDates.map((date) => ({
|
|
date,
|
|
isToday: date === today,
|
|
tasks: rows.filter((r) => r.date === date),
|
|
}));
|
|
const todayTasks = week.find((w) => w.isToday)?.tasks ?? [];
|
|
|
|
overview.kids.push({
|
|
id: kid.id,
|
|
name: kid.name,
|
|
streak,
|
|
today: {
|
|
total: todayTasks.length,
|
|
done: todayTasks.filter((t) => t.status === "done").length,
|
|
},
|
|
week,
|
|
});
|
|
}
|
|
|
|
// ===== 周模板 =====
|
|
const templateRows = kids.length
|
|
? await db
|
|
.select()
|
|
.from(taskTemplates)
|
|
.where(inArray(taskTemplates.childUserId, kids.map((k) => k.id)))
|
|
.orderBy(asc(taskTemplates.childUserId), asc(taskTemplates.dow), 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,
|
|
requiresPhoto: t.requiresPhoto,
|
|
})),
|
|
})),
|
|
};
|
|
|
|
// ===== 设备会话 =====
|
|
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: fmtDateTime(s.createdAt),
|
|
expiresAt: fmtDateTime(s.expiresAt),
|
|
isCurrent: s.tokenHash === tokenHash,
|
|
})),
|
|
};
|
|
|
|
return <ParentDashboard overview={overview} templates={templates} devices={devices} />;
|
|
}
|