diff --git a/docs/superpowers/plans/2026-08-19-family-manage.md b/docs/superpowers/plans/2026-08-19-family-manage.md new file mode 100644 index 0000000..1ad28ad --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-family-manage.md @@ -0,0 +1,469 @@ +# 家庭管理(邀请第二位家长)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` 表定义(`// ===== 设备激活码` 那一节)结束之后、`// ===== 周模板` 注释之前插入: + +```ts +// ===== 家庭邀请码(家长生成,其他家长凭码注册加入;一次性,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`** + +```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: 提交** + +```bash +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 = `{ error?: string } | null`)。 + +- [ ] **Step 1: 创建 `src/app/register/actions.ts`** + +```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 { + 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`** + +```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 ( +
+ + + +
+ +
+

加入家庭

+

需要家里已有成员的邀请码

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {state?.error &&

{state.error}

} + +

+ 已有账号?{" "} + + 去登录 + +

+
+
+
+
+ ); +} +``` + +- [ ] **Step 3: 登录页加注册入口** + +`src/app/login/page.tsx` 中「去输入激活码」那个 `

` 之后、`` 之前插入: + +```tsx +

+ 家里另一位家长想加入?{" "} + + 用邀请码加入家庭 + +

+``` + +- [ ] **Step 4: 类型检查** + +Run: `npx tsc --noEmit` +Expected: 无输出(退出码 0) + +- [ ] **Step 5: 提交** + +```bash +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`;文件内新增 `generateFamilyInvite` action) +- 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 之后追加: + +```ts +// 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`** + +```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 ( +
+

+ {familyName} · {parents.length} 位家长 · {kids.length} 个孩子 +

+
+ {parents.map((p) => ( +
+ {p.name} + 家长 +
+ ))} + {kids.map((k) => ( +
+ {k.name} + 孩子 +
+ ))} +
+
+ + {invite?.error &&

{invite.error}

} + {invite?.code && ( +

+ 邀请码{" "} + {invite.code} + 15 分钟有效,一次性 +

+ )} +
+
+ ); +} +``` + +- [ ] **Step 3: dashboard.tsx 加 `FamilyData` 类型、props 与 Section** + +a) `WeeklyRow` 类型定义之后追加: + +```ts +// 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 之前插入: + +```tsx +
+ +
+``` + +- [ ] **Step 4: page.tsx 查询并传入 family 数据** + +a) schema 导入行(第 4-16 行 `import { sessions, ... } from "@/db/schema";`)追加 `families`。 + +b) Dashboard 类型导入(第 27-34 行)追加 `FamilyData`。 + +c) 会话/周测查询之后、`return` 之前插入: + +```ts + // ===== 家庭管理(家庭名 + 全部成员) ===== + 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` 里 `` 追加 `family={family}` prop。 + +- [ ] **Step 5: 类型检查 + 构建** + +Run: `npx tsc --noEmit` +Expected: 无输出 + +Run: `npm run build` +Expected: 构建成功,`/register`、`/parent` 均出现在路由表 + +- [ ] **Step 6: 提交** + +```bash +git add src/app/parent +git commit -m "家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入" +``` + +--- + +## 手工测试清单(部署 jump.hn12301.net 前在本地 MySQL 可用时/或部署后执行) + +1. 家长端功能 tab → 「家庭管理」:显示家庭名、家长/孩子成员与计数。 +2. 点「生成邀请码」→ 显示 6 位码与「15 分钟有效,一次性」。 +3. 浏览器开 `/register`:输称呼+新手机号+密码+邀请码 → 注册成功自动进 `/parent`,看到全部孩子与数据。 +4. 第二家长登录(新手机号+密码)→ 能编辑课程/看积分,设备管理里出现两个家长会话。 +5. 同一邀请码二次注册 → 「邀请码无效或已过期」;过期(15 分钟)同样报错。 +6. 已注册手机号再注册 → 「该手机号已注册」;错误邀请码 → 「邀请码无效或已过期」。 +7. 登录页出现「用邀请码加入家庭」链接,可达 `/register`;孩子设备激活流程不受影响。 \ No newline at end of file