第一期:家庭自学打卡系统
- 家长手机号登录 + 孩子设备一次性激活(长期会话) - 周模板自动生成每日任务快照,支持拍照凭证直传对象存储 - 连击计算(空白日自动达成)、家长补卡带标记 - 家长端三标签:今日概览 / 任务模板 / 设备管理 - 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,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { photos, taskInstances } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { photoKey, uploadUrl } from "@/lib/oss";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// 取当前孩子会话,非法则抛错(客户端会显示)
|
||||
async function requireChild() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "child") throw new Error("会话无效,请重新激活设备");
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ownedTask(childId: number, instanceId: number) {
|
||||
const [task] = await db
|
||||
.select()
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.id, instanceId), eq(taskInstances.childUserId, childId)))
|
||||
.limit(1);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
return task;
|
||||
}
|
||||
|
||||
// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾)
|
||||
export async function toggleTask(instanceId: number, done: boolean) {
|
||||
const child = await requireChild();
|
||||
const task = await ownedTask(child.id, instanceId);
|
||||
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
|
||||
|
||||
if (done && task.requiresPhoto) {
|
||||
const [photo] = await db
|
||||
.select({ id: photos.id })
|
||||
.from(photos)
|
||||
.where(eq(photos.taskInstanceId, instanceId))
|
||||
.limit(1);
|
||||
if (!photo) throw new Error("这个任务要先拍照凭证哦");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(taskInstances)
|
||||
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
|
||||
.where(eq(taskInstances.id, instanceId));
|
||||
|
||||
revalidatePath("/kid");
|
||||
}
|
||||
|
||||
// 签发照片直传地址(浏览器 PUT 到对象存储)
|
||||
export async function getUploadUrl(instanceId: number) {
|
||||
const child = await requireChild();
|
||||
const task = await ownedTask(child.id, instanceId);
|
||||
const count = await db
|
||||
.select({ id: photos.id })
|
||||
.from(photos)
|
||||
.where(eq(photos.taskInstanceId, instanceId));
|
||||
if (count.length >= 3) throw new Error("最多上传 3 张照片");
|
||||
|
||||
const key = photoKey(child.id, task.date);
|
||||
return { url: uploadUrl(key, "image/jpeg"), key };
|
||||
}
|
||||
|
||||
// 直传完成后登记照片
|
||||
export async function confirmPhoto(instanceId: number, key: string) {
|
||||
const child = await requireChild();
|
||||
await ownedTask(child.id, instanceId);
|
||||
if (!key.startsWith(`photos/${child.id}/`)) throw new Error("照片路径非法");
|
||||
await db.insert(photos).values({ taskInstanceId: instanceId, ossKey: key });
|
||||
revalidatePath("/kid");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { streakOf } from "@/lib/streak";
|
||||
import { dayTasks } from "@/lib/tasks";
|
||||
import { TaskCard } from "./task-card";
|
||||
|
||||
export default async function KidPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const today = todayStr();
|
||||
const [tasks, streak] = await Promise.all([
|
||||
dayTasks(user.id, today),
|
||||
streakOf(user.id),
|
||||
]);
|
||||
const doneCount = tasks.filter((t) => t.status === "done").length;
|
||||
const allDone = tasks.length > 0 && doneCount === tasks.length;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg space-y-5 p-4 pb-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">你好,{user.displayName}</h1>
|
||||
<p className="text-sm text-muted-foreground">{today}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-full bg-orange-100 px-4 py-2 text-orange-600">
|
||||
<Flame className="size-6" fill="currentColor" />
|
||||
<span className="text-xl font-bold">{streak}</span>
|
||||
<span className="text-sm">天</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-2xl bg-muted p-8 text-center">
|
||||
<p className="text-2xl">🎉</p>
|
||||
<p className="mt-2 text-lg font-medium">今天没有任务</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">连击自动保持,去玩吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已完成 {doneCount} / {tasks.length} 项
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((t) => (
|
||||
<TaskCard key={t.id} task={t} />
|
||||
))}
|
||||
</div>
|
||||
{allDone && (
|
||||
<div className="rounded-2xl bg-green-100 p-6 text-center">
|
||||
<p className="text-2xl">💪</p>
|
||||
<p className="mt-2 text-lg font-bold text-green-700">今天全部完成,太棒了!</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { Camera, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmPhoto, getUploadUrl, toggleTask } from "./actions";
|
||||
|
||||
// 客户端压缩:最长边压到 1600 像素,JPEG 质量 0.8
|
||||
async function compress(file: File): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const max = 1600;
|
||||
const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("压缩失败"))), "image/jpeg", 0.8),
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
}: {
|
||||
task: {
|
||||
id: number;
|
||||
title: string;
|
||||
requiresPhoto: boolean;
|
||||
status: "pending" | "done";
|
||||
isMakeup: boolean;
|
||||
photoUrls: string[];
|
||||
};
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const done = task.status === "done";
|
||||
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
|
||||
|
||||
function onToggle() {
|
||||
setError("");
|
||||
if (!done && !canCheck) {
|
||||
setError("先拍张照片当凭证吧");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleTask(task.id, !done);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "操作失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError("");
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const blob = await compress(file);
|
||||
const { url, key } = await getUploadUrl(task.id);
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败(${res.status})`);
|
||||
await confirmPhoto(task.id, key);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "上传失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border-2 p-4 transition-colors ${
|
||||
done ? "border-green-500 bg-green-50" : "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
aria-label={done ? "取消完成" : "标记完成"}
|
||||
className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
done ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{done && <Check className="size-7" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-lg font-medium ${done ? "text-green-700 line-through" : ""}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{task.requiresPhoto && <Camera className="size-4" />}
|
||||
{task.requiresPhoto && <span>需拍照凭证</span>}
|
||||
{task.isMakeup && <span className="text-orange-500">补卡</span>}
|
||||
</div>
|
||||
</div>
|
||||
{task.requiresPhoto && task.photoUrls.length < 3 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
拍照
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{task.photoUrls.length > 0 && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{task.photoUrls.map((url) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt="凭证照片"
|
||||
className="size-16 rounded-lg border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-sm font-medium text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SuperJump",
|
||||
description: "家庭自学打卡",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
// 系统字体栈,不加载任何网络字体(避免构建与访问时请求境外服务)
|
||||
<html lang="zh-CN">
|
||||
<body className="min-h-dvh antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { createSession, destroySession } from "@/lib/auth";
|
||||
|
||||
const loginSchema = z.object({
|
||||
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
|
||||
password: z.string().min(6, "密码至少 6 位"),
|
||||
});
|
||||
|
||||
export type LoginState = { error?: string } | null;
|
||||
|
||||
// 家长登录:校验通过后建立 30 天会话并跳转家长端
|
||||
export async function login(_prev: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const parsed = loginSchema.safeParse({
|
||||
phone: formData.get("phone"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.phone, parsed.data.phone), eq(users.role, "parent")))
|
||||
.limit(1);
|
||||
|
||||
// 统一报错文案,不暴露"手机号是否存在"
|
||||
if (!user?.passwordHash) return { error: "手机号或密码错误" };
|
||||
const ok = await bcrypt.compare(parsed.data.password, user.passwordHash);
|
||||
if (!ok) return { error: "手机号或密码错误" };
|
||||
|
||||
await createSession(user.id, "家长设备", 30);
|
||||
redirect("/parent");
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await destroySession();
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { login } 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 LoginPage() {
|
||||
const [state, formAction, pending] = useActionState(login, 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-3xl">SuperJump</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="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="username"
|
||||
placeholder="11 位手机号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// 根路由:按会话角色分发到对应入口
|
||||
export default async function Home() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
redirect(user.role === "parent" ? "/parent" : "/kid");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user