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

- 家长手机号登录 + 孩子设备一次性激活(长期会话)
- 周模板自动生成每日任务快照,支持拍照凭证直传对象存储
- 连击计算(空白日自动达成)、家长补卡带标记
- 家长端三标签:今日概览 / 任务模板 / 设备管理
- 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");
}