18 KiB
家庭管理(邀请第二位家长)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 家长端可生成家庭邀请码,第二位家长凭码在 /register 注册加入家庭,登录后看到全部孩子数据(权限与现有家长一致)。
Architecture: 新增 family_invite 表(仿 activation_code:6 位码、一次性、15 分钟);新增 /register 家长注册页(server action 校验码→建 user→自动登录);家长端功能 tab 新增「家庭管理」Section(成员列表 + 生成邀请码按钮)。users.familyId 已按家庭隔离数据,第二位家长同 familyId 即自动可见全部数据,无需权限改造。
Tech Stack: Next.js 15.5 App Router、TypeScript、Drizzle ORM(mysql2)、bcryptjs、Tailwind/ui 组件(shadcn 风格)、zod。
Spec: docs/superpowers/specs/2026-08-19-family-manage-design.md
Global Constraints
- 项目无自动化测试框架,验证靠
npx tsc --noEmit+npm run build(每任务末尾跑 tsc,最终任务跑 build)+ 手工清单。 - 新代码注释遵循项目风格,新函数加一行
// xuchao:注释(用户全局规范)。 - 验证码/邀请码一律只存 sha256 哈希(
crypto.createHash("sha256").update(code).digest("hex")),明文只在生成后一次性返回给调用方。 - 迁移文件格式:文件头
-- xuchao:注释 +CREATE TABLE IF NOT EXISTS+ 幂等,语句间--> statement-breakpoint分隔(参照drizzle/0008_points.sql)。 - 邀请码规则固定:6 位数字、15 分钟过期、一次性(未用且未过期才算有效)。
- 手机号唯一(
uq_user_phone),注册时先查占用,报「该手机号已注册」。
Task 1:family_invite 表(schema + 迁移 0009)
Files:
- Modify:
src/db/schema.ts(在activationCodes表定义之后追加) - Create:
drizzle/0009_family-invite.sql
Interfaces:
-
Produces:
familyInvites表的 drizzle 对象(列:id/familyId/codeHash/createdBy/createdAt/expiresAt/usedAt),供 Task 2/3 的 server action 使用。 -
Step 1: schema.ts 追加 family_invite 表
在 src/db/schema.ts 中 activationCodes 表定义(// ===== 设备激活码 那一节)结束之后、// ===== 周模板 注释之前插入:
// ===== 家庭邀请码(家长生成,其他家长凭码注册加入;一次性,15 分钟)=====
export const familyInvites = mysqlTable("family_invite", {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
familyId: bigint("family_id", { mode: "number" })
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
codeHash: varchar("code_hash", { length: 64 }).notNull(),
createdBy: bigint("created_by", { mode: "number" })
.notNull()
.references(() => users.id),
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: datetime("expires_at").notNull(),
usedAt: datetime("used_at"),
});
- Step 2: 创建迁移文件
drizzle/0009_family-invite.sql
-- xuchao: 家庭邀请码——家长生成一次性邀请码,其他家长凭码注册加入家庭。幂等。
CREATE TABLE IF NOT EXISTS `family_invite` (
`id` bigint AUTO_INCREMENT NOT NULL,
`family_id` bigint NOT NULL,
`code_hash` varchar(64) NOT NULL,
`created_by` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` datetime NOT NULL,
`used_at` datetime,
CONSTRAINT `family_invite_id` PRIMARY KEY(`id`)
);
- Step 3: 类型检查
Run: npx tsc --noEmit
Expected: 无输出(退出码 0)
- Step 4: 提交
git add src/db/schema.ts drizzle/0009_family-invite.sql
git commit -m "新增 family_invite 家庭邀请码表(schema+0009迁移)"
Task 2:/register 注册页 + 登录页入口
Files:
- Create:
src/app/register/actions.ts - Create:
src/app/register/page.tsx - Modify:
src/app/login/page.tsx(登录按钮下的提示区加链接)
Interfaces:
-
Consumes: Task 1 的
familyInvites、现有users/createSession、bcryptjs。 -
Produces:
registerWithInvite(prev: RegisterState, formData: FormData): Promise<RegisterState>(RegisterState ={ error?: string } | null)。 -
Step 1: 创建
src/app/register/actions.ts
"use server";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { and, eq, gt, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/db";
import { familyInvites, users } from "@/db/schema";
import { createSession } from "@/lib/auth";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
const registerSchema = z.object({
displayName: z.string().trim().min(1, "请输入称呼").max(20, "称呼最多 20 字"),
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
password: z.string().min(6, "密码至少 6 位"),
code: z.string().regex(/^\d{6}$/, "邀请码是 6 位数字"),
});
export type RegisterState = { error?: string } | null;
// xuchao: 家长凭邀请码注册:校验码→查重手机号→事务(码标记已用+建家长)→自动登录跳家长中心
export async function registerWithInvite(
_prev: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const parsed = registerSchema.safeParse({
displayName: formData.get("displayName"),
phone: formData.get("phone"),
password: formData.get("password"),
code: formData.get("code"),
});
if (!parsed.success) return { error: parsed.error.issues[0].message };
const { displayName, phone, password, code } = parsed.data;
const [invite] = await db
.select({ id: familyInvites.id, familyId: familyInvites.familyId })
.from(familyInvites)
.where(
and(
eq(familyInvites.codeHash, hashCode(code)),
isNull(familyInvites.usedAt),
gt(familyInvites.expiresAt, new Date()),
),
)
.limit(1);
if (!invite) return { error: "邀请码无效或已过期,请让家长重新生成" };
const [dup] = await db.select({ id: users.id }).from(users).where(eq(users.phone, phone)).limit(1);
if (dup) return { error: "该手机号已注册" };
const passwordHash = await bcrypt.hash(password, 10);
const newUserId = await db.transaction(async (tx) => {
await tx.update(familyInvites).set({ usedAt: new Date() }).where(eq(familyInvites.id, invite.id));
const [created] = await tx
.insert(users)
.values({ familyId: invite.familyId, role: "parent", displayName, phone, passwordHash })
.$returningId();
return created.id;
});
await createSession(newUserId, "家长设备", 30);
redirect("/parent");
}
- Step 2: 创建
src/app/register/page.tsx
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { BackgroundBlobs } from "@/components/background-blobs";
import { registerWithInvite } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const [state, formAction, pending] = useActionState(registerWithInvite, null);
return (
<main className="relative flex min-h-dvh items-center justify-center p-4">
<BackgroundBlobs />
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
<CardHeader className="items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-btn">
<UserPlus className="size-8 text-white" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight">加入家庭</h1>
<p className="text-sm text-muted-foreground">需要家里已有成员的邀请码</p>
</CardHeader>
<CardContent>
<form action={formAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">称呼</Label>
<Input id="displayName" name="displayName" placeholder="如:妈妈" required />
</div>
<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="new-password"
placeholder="至少 6 位"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code">邀请码</Label>
<Input
id="code"
name="code"
inputMode="numeric"
maxLength={6}
placeholder="6 位数字"
className="font-mono tracking-widest"
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>
<p className="text-center text-sm text-muted-foreground">
已有账号?{" "}
<Link href="/login" className="font-semibold text-indigo-600 underline-offset-4 hover:underline">
去登录
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}
- Step 3: 登录页加注册入口
src/app/login/page.tsx 中「去输入激活码」那个 <p> 之后、</form> 之前插入:
<p className="text-center text-sm text-muted-foreground">
家里另一位家长想加入?{" "}
<Link
href="/register"
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
>
用邀请码加入家庭
</Link>
</p>
- Step 4: 类型检查
Run: npx tsc --noEmit
Expected: 无输出(退出码 0)
- Step 5: 提交
git add src/app/register src/app/login/page.tsx
git commit -m "新增家长注册页 /register:凭家庭邀请码注册加入并自动登录"
Task 3:家长端「家庭管理」Section
Files:
- Modify:
src/app/parent/actions.ts(schema 导入行加familyInvites;文件内新增generateFamilyInviteaction) - Create:
src/app/parent/family-manage.tsx - Modify:
src/app/parent/dashboard.tsx(新增FamilyData类型 + props + Section) - Modify:
src/app/parent/page.tsx(查询家庭名与家长列表,组装family传入 Dashboard)
Interfaces:
-
Consumes: Task 1 的
familyInvites;现有getSessionUser/db/families/users。 -
Produces:
generateFamilyInvite(): Promise<{ code?: string; error?: string }>;FamilyManage({ familyName, parents, kids })组件;FamilyData类型。 -
Step 1: parent/actions.ts 加
generateFamilyInvite
先改 schema 导入行(文件顶部 import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema"; → 追加 familyInvites);再在 createActivationCode action 之后追加:
// xuchao: 生成家庭邀请码(6 位,15 分钟一次性),对方凭码在 /register 注册加入
export async function generateFamilyInvite(): Promise<{ code?: string; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.insert(familyInvites).values({
familyId: parent.familyId,
codeHash: hashCode(code),
createdBy: parent.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
return { code };
}
- Step 2: 创建
src/app/parent/family-manage.tsx
"use client";
import { useState, useTransition } from "react";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { generateFamilyInvite } from "./actions";
// xuchao: 家庭管理——展示成员列表,生成一次性邀请码让其他家长注册加入
export function FamilyManage({
familyName,
parents,
kids,
}: {
familyName: string;
parents: { id: number; name: string }[];
kids: { id: number; name: string }[];
}) {
const [invite, setInvite] = useState<{ code?: string; error?: string } | null>(null);
const [pending, startTransition] = useTransition();
const gen = () => {
setInvite(null);
startTransition(async () => {
setInvite(await generateFamilyInvite());
});
};
return (
<div className="space-y-3">
<p className="text-sm text-slate-700">
<span className="font-semibold">{familyName}</span> · {parents.length} 位家长 · {kids.length} 个孩子
</p>
<div className="space-y-1.5">
{parents.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{p.name}</span>
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">家长</span>
</div>
))}
{kids.map((k) => (
<div key={k.id} className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
<span className="font-medium text-slate-800">{k.name}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">孩子</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="secondary" disabled={pending} onClick={gen}>
<UserPlus className="size-4" /> {pending ? "生成中…" : "生成邀请码"}
</Button>
{invite?.error && <p className="text-sm font-medium text-destructive">{invite.error}</p>}
{invite?.code && (
<p className="text-sm text-slate-700">
邀请码{" "}
<span className="font-mono text-lg font-bold tracking-widest text-indigo-600">{invite.code}</span>
<span className="ml-1 text-xs text-muted-foreground">15 分钟有效,一次性</span>
</p>
)}
</div>
</div>
);
}
- Step 3: dashboard.tsx 加
FamilyData类型、props 与 Section
a) WeeklyRow 类型定义之后追加:
// xuchao: 家庭管理数据——家庭名 + 家长成员 + 孩子成员
export type FamilyData = {
familyName: string;
parents: { id: number; name: string }[];
kids: { id: number; name: string }[];
};
b) 导入区加 import { FamilyManage } from "./family-manage";(与 ChildManage 导入相邻)。
c) ParentDashboard 函数签名 props 加 family: FamilyData;,({ overview, templates, devices, weekly, points, family }: { ...; family: FamilyData; })。
d) 「孩子管理」Section 之前插入:
<Section title="家庭管理" desc="添加家庭其他家长,一起查看孩子的学习情况">
<FamilyManage
familyName={family.familyName}
parents={family.parents}
kids={family.kids}
/>
</Section>
- Step 4: page.tsx 查询并传入 family 数据
a) schema 导入行(第 4-16 行 import { sessions, ... } from "@/db/schema";)追加 families。
b) Dashboard 类型导入(第 27-34 行)追加 FamilyData。
c) 会话/周测查询之后、return 之前插入:
// ===== 家庭管理(家庭名 + 全部成员) =====
const [familyRow] = await db
.select({ name: families.name })
.from(families)
.where(eq(families.id, parent.familyId))
.limit(1);
const parentRows = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(and(eq(users.familyId, parent.familyId), eq(users.role, "parent")))
.orderBy(asc(users.id));
const family: FamilyData = {
familyName: familyRow?.name ?? "我的家庭",
parents: parentRows.map((p) => ({ id: p.id, name: p.displayName })),
kids: kids.map((k) => ({ id: k.id, name: k.name })),
};
d) return 里 <ParentDashboard ... /> 追加 family={family} prop。
- Step 5: 类型检查 + 构建
Run: npx tsc --noEmit
Expected: 无输出
Run: npm run build
Expected: 构建成功,/register、/parent 均出现在路由表
- Step 6: 提交
git add src/app/parent
git commit -m "家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入"
手工测试清单(部署 jump.hn12301.net 前在本地 MySQL 可用时/或部署后执行)
- 家长端功能 tab → 「家庭管理」:显示家庭名、家长/孩子成员与计数。
- 点「生成邀请码」→ 显示 6 位码与「15 分钟有效,一次性」。
- 浏览器开
/register:输称呼+新手机号+密码+邀请码 → 注册成功自动进/parent,看到全部孩子与数据。 - 第二家长登录(新手机号+密码)→ 能编辑课程/看积分,设备管理里出现两个家长会话。
- 同一邀请码二次注册 → 「邀请码无效或已过期」;过期(15 分钟)同样报错。
- 已注册手机号再注册 → 「该手机号已注册」;错误邀请码 → 「邀请码无效或已过期」。
- 登录页出现「用邀请码加入家庭」链接,可达
/register;孩子设备激活流程不受影响。