家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入

This commit is contained in:
jacobxu666
2026-08-19 15:24:38 +08:00
parent df41f7713d
commit 145ba1b7d1
4 changed files with 116 additions and 1 deletions
+16 -1
View File
@@ -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();
+18
View File
@@ -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>
+62
View File
@@ -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>
);
}
+20
View File
@@ -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}
/>
);
}