第一期:家庭自学打卡系统
- 家长手机号登录 + 孩子设备一次性激活(长期会话) - 周模板自动生成每日任务快照,支持拍照凭证直传对象存储 - 连击计算(空白日自动达成)、家长补卡带标记 - 家长端三标签:今日概览 / 任务模板 / 设备管理 - Docker 独立输出,经 Traefik 接入 jump.hn12301.net Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user