Files
SuperJump/docs/superpowers/plans/2026-08-19-family-manage.md

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;文件内新增 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 之后追加:

// 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 可用时/或部署后执行)

  1. 家长端功能 tab → 「家庭管理」:显示家庭名、家长/孩子成员与计数。
  2. 点「生成邀请码」→ 显示 6 位码与「15 分钟有效,一次性」。
  3. 浏览器开 /register:输称呼+新手机号+密码+邀请码 → 注册成功自动进 /parent,看到全部孩子与数据。
  4. 第二家长登录(新手机号+密码)→ 能编辑课程/看积分,设备管理里出现两个家长会话。
  5. 同一邀请码二次注册 → 「邀请码无效或已过期」;过期(15 分钟)同样报错。
  6. 已注册手机号再注册 → 「该手机号已注册」;错误邀请码 → 「邀请码无效或已过期」。
  7. 登录页出现「用邀请码加入家庭」链接,可达 /register;孩子设备激活流程不受影响。