Compare commits
6
Commits
f6d5aa8680
...
348dad28fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
348dad28fe | ||
|
|
145ba1b7d1 | ||
|
|
df41f7713d | ||
|
|
da7f7a6a19 | ||
|
|
8c9d8ece1a | ||
|
|
4ac4e2091a |
@@ -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>`(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<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`**
|
||||
|
||||
```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>` 之前插入:
|
||||
|
||||
```tsx
|
||||
<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: 提交**
|
||||
|
||||
```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 (
|
||||
<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` 类型定义之后追加:
|
||||
|
||||
```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
|
||||
<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` 之前插入:
|
||||
|
||||
```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` 里 `<ParentDashboard ... />` 追加 `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`;孩子设备激活流程不受影响。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 家庭管理:邀请第二位家长加入 - 设计文档
|
||||
|
||||
日期:2026-08-19
|
||||
状态:已批准
|
||||
|
||||
## 背景
|
||||
|
||||
家长端目前只有 seed 脚本创建的首位家长(手机号+密码登录),家庭内无法自助添加其他家长(如配偶)。
|
||||
用户希望:能添加家庭其他家长,第二位家长也能查看孩子的学习情况。
|
||||
|
||||
关键现状:
|
||||
|
||||
- `users.familyId` 已支持一家庭多家长;`/parent` 所有查询按 familyId 隔离,
|
||||
**第二位家长只要拿到同一 familyId 即自动看到全部孩子数据,无需权限改造**。
|
||||
- 首位家长账号由 `scripts/seed.ts` 创建,生产无注册页。
|
||||
- 孩子加入:家长生成 6 位激活码(`activation_code` 表,一次性+15分钟) → 孩子设备输入 → 2 年设备会话。
|
||||
- `families` 表已有 `name` 字段(seed 写入),尚未展示。
|
||||
|
||||
## 需求(已与用户确认)
|
||||
|
||||
1. 家长端「家庭管理」可生成邀请码,第二位家长凭码注册加入家庭。
|
||||
2. 新加入家长权限与现有家长完全一样(概览/课程/积分/设备/孩子管理全可见)。
|
||||
3. 本次不做成员移除(用户选定)。
|
||||
4. 邀请码:6 位数字、一次性、15 分钟有效(与孩子激活码一致)。
|
||||
|
||||
## 方案选择
|
||||
|
||||
- **方案 A(采用):独立邀请码表 `family_invite` + 新注册页 `/register`**
|
||||
- 仿 `activation_code` 既有模式,注册页表单(称呼+手机号+密码+邀请码),对方自设密码。
|
||||
- 方案 B(弃):家长代建账号直接填手机号+初始密码 → 对方无法自选密码,需线下传递。
|
||||
- 方案 C(弃):复用 `activation_code` 表 → 该表按 childUserId 绑定,语义冲突。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. 数据模型(schema.ts + 迁移 0009_family-invite.sql)
|
||||
|
||||
```ts
|
||||
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"),
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 注册页:`src/app/register/page.tsx` + `actions.ts`(仿 join,server action + useActionState)
|
||||
|
||||
- 表单:称呼、手机号(11 位)、密码(≥6 位)、邀请码(6 位数字)。
|
||||
- `registerWithInvite(prev, formData)` 逻辑:
|
||||
1. zod 校验表单;邀请码 sha256 查询 `family_invite`(未用且未过期),失败统一报「邀请码无效或已过期,请让家长重新生成」;
|
||||
2. 校验手机号未被占用(`users.phone` 唯一),占用报「该手机号已注册」;
|
||||
3. 事务:标记邀请码已用 → 插入 `user(role=parent, familyId=邀请码家庭, phone, passwordHash)` → `createSession(30 天)` → `redirect("/parent")`。
|
||||
- 密码 bcrypt 哈希(与 login 一致)。
|
||||
|
||||
### 3. 登录页入口
|
||||
|
||||
- `src/app/login/page.tsx` 底部「去输入激活码」旁加「没有账号?用邀请码加入家庭」链接 → `/register`。
|
||||
|
||||
### 4. 家长端「家庭管理」卡片
|
||||
|
||||
- `src/components` 或 `src/app/parent/family-manage.tsx`(client 组件,仿 child-manage):
|
||||
- 展示:家庭名 + 成员列表(家长显示称呼+「家长」徽标,孩子显示名字+「孩子」徽标);
|
||||
- 「生成邀请码」按钮 → 调 `createFamilyInvite` action → 内联显示 6 位码 +「15 分钟有效,一次性」;
|
||||
- 允许同时存在多个未用邀请码,不做互斥。
|
||||
- dashboard.tsx「功能」tab 中与孩子管理并列插入该卡片;`/parent/page.tsx` 查询家庭成员数据传入:
|
||||
- `parents`: `users.role=parent AND familyId=parent.familyId`
|
||||
- `kids`: 已有查询复用
|
||||
- `familyName`: `families` 表按 familyId 查询
|
||||
- action `createFamilyInvite`(parent/actions.ts):取会话家长 → 生成 6 位随机数字码(与 createActivationCode 同法,15 分钟过期) → 存 hash → 返回码明文给调用方展示。
|
||||
|
||||
### 5. 不改动
|
||||
|
||||
- 孩子激活流程、`login` 逻辑、`activation_code` 表、`devices-tab`。
|
||||
|
||||
## 测试清单(手工,项目无自动化测试框架)
|
||||
|
||||
1. 家长端功能 tab → 家庭管理卡片:显示家庭名与全部成员。
|
||||
2. 生成邀请码 → 显示 6 位码;15 分钟后(或码用过后)注册报「邀请码无效或已过期」。
|
||||
3. `/register` 成功注册 → 自动登录进入 `/parent`,看到全部孩子与数据(概览/错题/周测)。
|
||||
4. 第二家长登录后:设备管理能看到两家长会话;课程/积分改动互相可见。
|
||||
5. 手机号已注册(任意家庭) → 报「该手机号已注册」。
|
||||
6. 孩子设备激活流程不受影响;登录页新链接可达 `/register`。
|
||||
7. 部署 jump.hn12301.net 后线上复验。
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 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`)
|
||||
);
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1786233600000,
|
||||
"tag": "0008_points",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1786320000000,
|
||||
"tag": "0009_family-invite",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,6 +65,15 @@ export default function LoginPage() {
|
||||
去输入激活码
|
||||
</Link>
|
||||
</p>
|
||||
<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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { activationCodes, familyInvites, sessions, taskInstances, taskTemplates, users } from "@/db/schema";
|
||||
import { SUBJECT_VALUES } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { ensureDay } from "@/lib/tasks";
|
||||
@@ -51,6 +51,21 @@ export async function createActivationCode(
|
||||
return { code, childName: child.displayName };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
|
||||
export async function makeupTask(instanceId: number, done: boolean) {
|
||||
const parent = await getSessionUser();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChildManage } from "./child-manage";
|
||||
import { DevicesTab } from "./devices-tab";
|
||||
import { FamilyManage } from "./family-manage";
|
||||
import { OverviewTab } from "./overview-tab";
|
||||
import { PointsManage } from "./points-manage";
|
||||
import { TemplatesTab } from "./templates-tab";
|
||||
@@ -69,6 +70,13 @@ export type DevicesData = {
|
||||
};
|
||||
export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean };
|
||||
|
||||
// xuchao: 家庭管理数据——家庭名 + 家长成员 + 孩子成员
|
||||
export type FamilyData = {
|
||||
familyName: string;
|
||||
parents: { id: number; name: string }[];
|
||||
kids: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft";
|
||||
|
||||
function Section({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) {
|
||||
@@ -87,12 +95,14 @@ export function ParentDashboard({
|
||||
devices,
|
||||
weekly,
|
||||
points,
|
||||
family,
|
||||
}: {
|
||||
overview: OverviewData;
|
||||
templates: TemplatesData;
|
||||
devices: DevicesData;
|
||||
weekly: WeeklyRow[];
|
||||
points: PointsData;
|
||||
family: FamilyData;
|
||||
}) {
|
||||
return (
|
||||
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
|
||||
@@ -165,6 +175,14 @@ export function ParentDashboard({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="家庭管理" desc="添加家庭其他家长,一起查看孩子的学习情况">
|
||||
<FamilyManage
|
||||
familyName={family.familyName}
|
||||
parents={family.parents}
|
||||
kids={family.kids}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="孩子管理" desc="增加或删除孩子,删除会连同其记录一起删除">
|
||||
<ChildManage kids={overview.kids.map((k) => ({ id: k.id, name: k.name }))} />
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
weeklyTest,
|
||||
wrongBook,
|
||||
wordRecording,
|
||||
families,
|
||||
} from "@/db/schema";
|
||||
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
||||
import { addDays, dowOf, todayStr } from "@/lib/dates";
|
||||
@@ -27,6 +28,7 @@ import { viewUrl } from "@/lib/oss";
|
||||
import {
|
||||
ParentDashboard,
|
||||
type DevicesData,
|
||||
type FamilyData,
|
||||
type OverviewData,
|
||||
type PointsData,
|
||||
type TemplatesData,
|
||||
@@ -219,6 +221,23 @@ export default async function ParentPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 家庭管理(家庭名 + 全部成员) =====
|
||||
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 })),
|
||||
};
|
||||
|
||||
return (
|
||||
<ParentDashboard
|
||||
overview={overview}
|
||||
@@ -226,6 +245,7 @@ export default async function ParentPage() {
|
||||
devices={devices}
|
||||
weekly={weekly}
|
||||
points={pointsData}
|
||||
family={family}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"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");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,21 @@ export const activationCodes = mysqlTable("activation_code", {
|
||||
usedAt: datetime("used_at"),
|
||||
});
|
||||
|
||||
// ===== 家庭邀请码(家长生成,其他家长凭码注册加入;一次性,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"),
|
||||
});
|
||||
|
||||
// ===== 周模板(每个孩子 × 每周几 × 每条任务)=====
|
||||
export const taskTemplates = mysqlTable(
|
||||
"task_template",
|
||||
|
||||
Reference in New Issue
Block a user