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

- 家长手机号登录 + 孩子设备一次性激活(长期会话)
- 周模板自动生成每日任务快照,支持拍照凭证直传对象存储
- 连击计算(空白日自动达成)、家长补卡带标记
- 家长端三标签:今日概览 / 任务模板 / 设备管理
- 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
+53
View File
@@ -0,0 +1,53 @@
"use server";
import crypto from "node:crypto";
import { and, eq, gt, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { activationCodes, users } from "@/db/schema";
import { createSession } from "@/lib/auth";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
export type ActivateState = { error?: string } | null;
// 孩子设备激活:输入 6 位激活码 → 绑定该设备为对应孩子的长期会话(2 年)
export async function activate(
_prev: ActivateState,
formData: FormData,
): Promise<ActivateState> {
const code = String(formData.get("code") ?? "").trim();
if (!/^\d{6}$/.test(code)) return { error: "激活码是 6 位数字" };
const [row] = await db
.select({ id: activationCodes.id, childUserId: activationCodes.childUserId })
.from(activationCodes)
.where(
and(
eq(activationCodes.codeHash, hashCode(code)),
isNull(activationCodes.usedAt),
gt(activationCodes.expiresAt, new Date()),
),
)
.limit(1);
if (!row) return { error: "激活码无效或已过期,请让家长重新生成" };
const [child] = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(eq(users.id, row.childUserId))
.limit(1);
if (!child) return { error: "激活码对应的孩子不存在" };
// 标记激活码已用,并建立 2 年的设备会话
await db
.update(activationCodes)
.set({ usedAt: new Date() })
.where(eq(activationCodes.id, row.id));
await createSession(child.id, `${child.displayName} 的 iPad`, 730);
redirect("/kid");
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useActionState } from "react";
import { activate } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function JoinPage() {
const [state, formAction, pending] = useActionState(activate, null);
return (
<main className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">激活这台设备</CardTitle>
<p className="text-sm text-muted-foreground">
请让家长在家长端生成激活码,然后输入下面的框里
</p>
</CardHeader>
<CardContent>
<form action={formAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">6 位激活码</Label>
<Input
id="code"
name="code"
type="text"
inputMode="numeric"
pattern="\d{6}"
maxLength={6}
autoComplete="one-time-code"
className="text-center text-3xl tracking-[0.5em] font-bold"
placeholder="······"
required
/>
</div>
{state?.error && (
<p className="text-sm font-medium text-destructive">{state.error}</p>
)}
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? "激活中…" : "激活"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}