第一期:家庭自学打卡系统

- 家长手机号登录 + 孩子设备一次性激活(长期会话)
- 周模板自动生成每日任务快照,支持拍照凭证直传对象存储
- 连击计算(空白日自动达成)、家长补卡带标记
- 家长端三标签:今日概览 / 任务模板 / 设备管理
- Docker 独立输出,经 Traefik 接入 jump.hn12301.net

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-02 23:19:13 +08:00
co-authored by Claude Opus 4.8
commit af7a144d58
57 changed files with 8875 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
"use server";
import crypto from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { getSessionUser } from "@/lib/auth";
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 };
}
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
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),
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
requiresPhoto: z.boolean(),
});
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"),
title: formData.get("title"),
requiresPhoto: formData.get("requiresPhoto") === "on",
});
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(
and(
eq(taskTemplates.childUserId, parsed.data.childId),
eq(taskTemplates.dow, parsed.data.dow),
),
);
await db.insert(taskTemplates).values({
childUserId: parsed.data.childId,
dow: parsed.data.dow,
title: parsed.data.title,
requiresPhoto: parsed.data.requiresPhoto,
sortOrder: maxRow.maxSort + 1,
});
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 })
.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));
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");
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useActionState } from "react";
import { createActivationCode, type CreateCodeState } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
// 激活码生成卡片:点按钮生成,大号显示 6 位码,15 分钟有效
export function ActivationCard({
childId,
childName,
}: {
childId: number;
childName: string;
}) {
const [state, formAction, pending] = useActionState<CreateCodeState, FormData>(
createActivationCode,
null,
);
return (
<Card>
<CardContent className="flex items-center justify-between gap-4 p-4">
<div>
<p className="font-medium">{childName}</p>
<p className="text-xs text-muted-foreground">
{state?.code
? "激活码 15 分钟内有效,只用一次"
: "在孩子的 iPad 上打开网站,输入激活码"}
</p>
</div>
{state?.code ? (
<span className="text-3xl font-bold tracking-[0.3em]">{state.code}</span>
) : (
<form action={formAction}>
<input type="hidden" name="childId" value={childId} />
<Button type="submit" disabled={pending}>
{pending ? "生成中…" : "生成激活码"}
</Button>
</form>
)}
</CardContent>
</Card>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { logout } from "@/app/login/actions";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DevicesTab } from "./devices-tab";
import { OverviewTab } from "./overview-tab";
import { TemplatesTab } from "./templates-tab";
export type OverviewData = {
kids: {
id: number;
name: string;
streak: number;
today: { done: number; total: number };
week: {
date: string;
isToday: boolean;
tasks: { id: number; title: string; status: "pending" | "done"; isMakeup: boolean }[];
}[];
}[];
};
export type TemplatesData = {
kids: {
id: number;
name: string;
items: { id: number; dow: number; title: string; requiresPhoto: boolean }[];
}[];
};
export type DevicesData = {
rows: {
id: number;
owner: string;
deviceLabel: string | null;
createdAt: string;
expiresAt: string;
isCurrent: boolean;
}[];
};
export function ParentDashboard({
overview,
templates,
devices,
}: {
overview: OverviewData;
templates: TemplatesData;
devices: DevicesData;
}) {
const kids = overview.kids.map((k) => ({ id: k.id, name: k.name }));
return (
<main className="mx-auto max-w-2xl space-y-5 p-4 pb-10">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-bold">家长端</h1>
<form action={logout}>
<Button type="submit" variant="outline" size="sm">
退出登录
</Button>
</form>
</header>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">今日概览</TabsTrigger>
<TabsTrigger value="templates">任务模板</TabsTrigger>
<TabsTrigger value="devices">设备管理</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab data={overview} />
</TabsContent>
<TabsContent value="templates" className="mt-4">
<TemplatesTab data={templates} />
</TabsContent>
<TabsContent value="devices" className="mt-4">
<DevicesTab data={devices} kids={kids} />
</TabsContent>
</Tabs>
</main>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { revokeSession } from "./actions";
import { ActivationCard } from "./activation-card";
import type { DevicesData } from "./dashboard";
export function DevicesTab({
data,
kids,
}: {
data: DevicesData;
kids: { id: number; name: string }[];
}) {
const [, startTransition] = useTransition();
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">新设备激活</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{kids.map((kid) => (
<ActivationCard key={kid.id} childId={kid.id} childName={kid.name} />
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">当前登录设备</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{data.rows.map((row) => (
<div
key={row.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<p className="font-medium">
{row.owner}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{row.deviceLabel ?? "未知设备"}
</span>
</p>
<p className="text-xs text-muted-foreground">
{row.createdAt} 激活 · 有效期至 {row.expiresAt}
</p>
</div>
{row.isCurrent ? (
<span className="shrink-0 text-xs text-muted-foreground">当前设备</span>
) : (
<Button
variant="outline"
size="sm"
className="shrink-0 text-destructive"
onClick={() => startTransition(() => revokeSession(row.id))}
>
注销
</Button>
)}
</div>
))}
</CardContent>
</Card>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useTransition } from "react";
import { Flame } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { makeupTask } from "./actions";
import type { OverviewData } from "./dashboard";
const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function dateLabel(date: string) {
const [y, m, d] = date.split("-").map(Number);
const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return `${m}月${d}日 ${DOW_LABELS[dow]}`;
}
export function OverviewTab({ data }: { data: OverviewData }) {
const [, startTransition] = useTransition();
return (
<div className="space-y-4">
{data.kids.map((kid) => (
<Card key={kid.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg">{kid.name}</CardTitle>
<div className="flex items-center gap-1 text-orange-600">
<Flame className="size-5" fill="currentColor" />
<span className="font-bold">{kid.streak}</span>
<span className="text-xs">天连击</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
今日完成 {kid.today.done} / {kid.today.total} 项
</p>
<div className="space-y-2">
{kid.week.map((day) => (
<div key={day.date} className="rounded-lg border p-2">
<p className="mb-1 text-xs font-medium text-muted-foreground">
{dateLabel(day.date)}
{day.isToday && " · 今天"}
</p>
{day.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground">无任务(自动保持连击)</p>
) : (
<div className="flex flex-wrap gap-1.5">
{day.tasks.map((t) => (
<span key={t.id} className="inline-flex items-center gap-1">
<Badge variant={t.status === "done" ? "default" : "secondary"}>
{t.title}
{t.isMakeup && " · 补"}
</Badge>
{!day.isToday && t.status === "pending" && (
<Button
variant="outline"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={() =>
startTransition(() => makeupTask(t.id, true))
}
>
补卡
</Button>
)}
{!day.isToday && t.isMakeup && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={() =>
startTransition(() => makeupTask(t.id, false))
}
>
撤销
</Button>
)}
</span>
))}
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
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} />;
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useState, useTransition } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { addTemplate, deleteTemplate } from "./actions";
import type { TemplatesData } from "./dashboard";
const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0]; // 展示顺序:周一到周日
const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
export function TemplatesTab({ data }: { data: TemplatesData }) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
模板改动从第二天起生效,不影响已生成的历史任务。
</p>
{data.kids.map((kid) => (
<KidTemplateCard key={kid.id} kid={kid} />
))}
</div>
);
}
function KidTemplateCard({
kid,
}: {
kid: TemplatesData["kids"][number];
}) {
const [dow, setDow] = useState(1);
const [title, setTitle] = useState("");
const [requiresPhoto, setRequiresPhoto] = useState(false);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
function onAdd(e: React.FormEvent) {
e.preventDefault();
setError("");
const fd = new FormData();
fd.set("childId", String(kid.id));
fd.set("dow", String(dow));
fd.set("title", title);
if (requiresPhoto) fd.set("requiresPhoto", "on");
startTransition(async () => {
try {
await addTemplate(fd);
setTitle("");
setRequiresPhoto(false);
} catch (err) {
setError(err instanceof Error ? err.message : "添加失败");
}
});
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">{kid.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{WEEK_ORDER.map((d) => {
const items = kid.items.filter((i) => i.dow === d);
return (
<div key={d} className="flex items-start gap-3">
<span className="w-10 shrink-0 pt-1 text-sm font-medium text-muted-foreground">
{DOW_LABELS[d]}
</span>
<div className="flex flex-1 flex-wrap gap-1.5">
{items.length === 0 && (
<span className="text-sm text-muted-foreground">休息日(自动保持连击)</span>
)}
{items.map((item) => (
<span
key={item.id}
className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1 text-sm"
>
{item.title}
{item.requiresPhoto && " · 拍照"}
<button
type="button"
aria-label={`删除 ${item.title}`}
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() => startTransition(() => deleteTemplate(item.id))}
>
<Trash2 className="size-3.5" />
</button>
</span>
))}
</div>
</div>
);
})}
</div>
<form onSubmit={onAdd} className="flex flex-wrap items-end gap-3 border-t pt-3">
<div className="space-y-1">
<Label>星期</Label>
<select
value={dow}
onChange={(e) => setDow(Number(e.target.value))}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
>
{WEEK_ORDER.map((d) => (
<option key={d} value={d}>
{DOW_LABELS[d]}
</option>
))}
</select>
</div>
<div className="min-w-40 flex-1 space-y-1">
<Label>任务内容</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:看书 30 分钟"
maxLength={100}
/>
</div>
<div className="flex h-9 items-center gap-2">
<Checkbox
id={`photo-${kid.id}`}
checked={requiresPhoto}
onCheckedChange={(v) => setRequiresPhoto(v === true)}
/>
<Label htmlFor={`photo-${kid.id}`}>需拍照凭证</Label>
</div>
<Button type="submit" disabled={pending || title.trim() === ""}>
添加
</Button>
</form>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</CardContent>
</Card>
);
}