家长端新增家庭管理:成员列表+生成邀请码,第二位家长凭码注册加入
This commit is contained in:
@@ -5,7 +5,7 @@ import { and, eq, sql } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
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 { SUBJECT_VALUES } from "@/db/schema";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import { ensureDay } from "@/lib/tasks";
|
import { ensureDay } from "@/lib/tasks";
|
||||||
@@ -51,6 +51,21 @@ export async function createActivationCode(
|
|||||||
return { code, childName: child.displayName };
|
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) {
|
export async function makeupTask(instanceId: number, done: boolean) {
|
||||||
const parent = await getSessionUser();
|
const parent = await getSessionUser();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ChildManage } from "./child-manage";
|
import { ChildManage } from "./child-manage";
|
||||||
import { DevicesTab } from "./devices-tab";
|
import { DevicesTab } from "./devices-tab";
|
||||||
|
import { FamilyManage } from "./family-manage";
|
||||||
import { OverviewTab } from "./overview-tab";
|
import { OverviewTab } from "./overview-tab";
|
||||||
import { PointsManage } from "./points-manage";
|
import { PointsManage } from "./points-manage";
|
||||||
import { TemplatesTab } from "./templates-tab";
|
import { TemplatesTab } from "./templates-tab";
|
||||||
@@ -69,6 +70,13 @@ export type DevicesData = {
|
|||||||
};
|
};
|
||||||
export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean };
|
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";
|
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 }) {
|
function Section({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) {
|
||||||
@@ -87,12 +95,14 @@ export function ParentDashboard({
|
|||||||
devices,
|
devices,
|
||||||
weekly,
|
weekly,
|
||||||
points,
|
points,
|
||||||
|
family,
|
||||||
}: {
|
}: {
|
||||||
overview: OverviewData;
|
overview: OverviewData;
|
||||||
templates: TemplatesData;
|
templates: TemplatesData;
|
||||||
devices: DevicesData;
|
devices: DevicesData;
|
||||||
weekly: WeeklyRow[];
|
weekly: WeeklyRow[];
|
||||||
points: PointsData;
|
points: PointsData;
|
||||||
|
family: FamilyData;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
|
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
|
||||||
@@ -165,6 +175,14 @@ export function ParentDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="家庭管理" desc="添加家庭其他家长,一起查看孩子的学习情况">
|
||||||
|
<FamilyManage
|
||||||
|
familyName={family.familyName}
|
||||||
|
parents={family.parents}
|
||||||
|
kids={family.kids}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="孩子管理" desc="增加或删除孩子,删除会连同其记录一起删除">
|
<Section title="孩子管理" desc="增加或删除孩子,删除会连同其记录一起删除">
|
||||||
<ChildManage kids={overview.kids.map((k) => ({ id: k.id, name: k.name }))} />
|
<ChildManage kids={overview.kids.map((k) => ({ id: k.id, name: k.name }))} />
|
||||||
</Section>
|
</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,
|
weeklyTest,
|
||||||
wrongBook,
|
wrongBook,
|
||||||
wordRecording,
|
wordRecording,
|
||||||
|
families,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
||||||
import { addDays, dowOf, todayStr } from "@/lib/dates";
|
import { addDays, dowOf, todayStr } from "@/lib/dates";
|
||||||
@@ -27,6 +28,7 @@ import { viewUrl } from "@/lib/oss";
|
|||||||
import {
|
import {
|
||||||
ParentDashboard,
|
ParentDashboard,
|
||||||
type DevicesData,
|
type DevicesData,
|
||||||
|
type FamilyData,
|
||||||
type OverviewData,
|
type OverviewData,
|
||||||
type PointsData,
|
type PointsData,
|
||||||
type TemplatesData,
|
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 (
|
return (
|
||||||
<ParentDashboard
|
<ParentDashboard
|
||||||
overview={overview}
|
overview={overview}
|
||||||
@@ -226,6 +245,7 @@ export default async function ParentPage() {
|
|||||||
devices={devices}
|
devices={devices}
|
||||||
weekly={weekly}
|
weekly={weekly}
|
||||||
points={pointsData}
|
points={pointsData}
|
||||||
|
family={family}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user