家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user