feat: wire auth and CRUD UI to APIs
This commit is contained in:
7
modules/auth/components/AuthGate.tsx
Normal file
7
modules/auth/components/AuthGate.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
type AuthGateProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
||||
232
modules/auth/components/AuthPanel.tsx
Normal file
232
modules/auth/components/AuthPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { FormEvent, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowRight, Building2, ShieldCheck, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
|
||||
type AuthMode = "login" | "register" | "setup";
|
||||
|
||||
type AuthPanelProps = {
|
||||
mode: AuthMode;
|
||||
};
|
||||
|
||||
const modeCopy: Record<AuthMode, { title: string; badge: string; submit: string }> = {
|
||||
login: { title: "登录", badge: "Welcome back", submit: "进入系统" },
|
||||
register: { title: "注册账号", badge: "Staff account", submit: "创建账号" },
|
||||
setup: { title: "初始化机构", badge: "First run", submit: "完成初始化" },
|
||||
};
|
||||
|
||||
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirectTo") ?? "/app/dashboard";
|
||||
const [message, setMessage] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [hasAccounts, setHasAccounts] = useState<boolean | null>(null);
|
||||
|
||||
const copy = modeCopy[mode];
|
||||
const isSetup = mode === "setup";
|
||||
const isLogin = mode === "login";
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadState(): Promise<void> {
|
||||
const [bootstrapResponse, sessionResponse] = await Promise.all([
|
||||
fetch("/api/auth/bootstrap"),
|
||||
fetch("/api/auth/session"),
|
||||
]);
|
||||
const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>;
|
||||
const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>;
|
||||
|
||||
if (!isMounted || !bootstrap.success || !session.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialized = !bootstrap.setupRequired;
|
||||
setHasAccounts(initialized);
|
||||
|
||||
if (mode !== "setup" && !initialized) {
|
||||
router.replace("/setup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "setup" && initialized) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.account) {
|
||||
router.replace("/app/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
void loadState();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [mode, router]);
|
||||
|
||||
const secondaryAction = useMemo(() => {
|
||||
if (isSetup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLogin) {
|
||||
return { href: "/register", label: "注册" };
|
||||
}
|
||||
|
||||
return { href: "/login", label: "登录" };
|
||||
}, [isLogin, isSetup]);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setMessage("");
|
||||
setIsPending(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const name = String(formData.get("name") ?? "");
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const organization = String(formData.get("organization") ?? "");
|
||||
|
||||
const endpoint = isLogin ? "/api/auth/login" : isSetup ? "/api/auth/setup" : "/api/auth/register";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
organization,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ account: unknown }>;
|
||||
setIsPending(false);
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
router.replace(isLogin ? redirectTo : "/app/dashboard");
|
||||
}
|
||||
|
||||
if (hasAccounts === null) {
|
||||
return <div className="h-96" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh bg-background lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<section className="hidden border-r bg-primary text-primary-foreground lg:flex lg:flex-col lg:justify-between lg:p-10">
|
||||
<Link href="/login" className="inline-flex items-center gap-3 text-sm font-medium">
|
||||
<span className="inline-flex size-10 items-center justify-center rounded-md bg-white/12">
|
||||
<Stethoscope className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
TeaTea 康护
|
||||
</Link>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Badge className="bg-white/12 text-primary-foreground">{copy.badge}</Badge>
|
||||
<h1 className="mt-5 text-5xl font-semibold tracking-normal">{copy.title}</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-primary-foreground/72">
|
||||
简洁、稳定、面向机构照护。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-primary-foreground/72">
|
||||
<span>档案</span>
|
||||
<span>护理</span>
|
||||
<span>预警</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-svh items-center justify-center px-5 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex items-center justify-between lg:hidden">
|
||||
<Link href="/login" className="inline-flex items-center gap-2 text-sm font-semibold">
|
||||
<Stethoscope className="size-5 text-primary" aria-hidden="true" />
|
||||
TeaTea 康护
|
||||
</Link>
|
||||
<Badge variant="secondary">{copy.badge}</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="border bg-white/82 p-6 shadow-none backdrop-blur">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex size-11 items-center justify-center rounded-md bg-secondary text-primary">
|
||||
{isSetup ? (
|
||||
<Building2 className="size-5" aria-hidden="true" />
|
||||
) : (
|
||||
<ShieldCheck className="size-5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold">{copy.title}</h2>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{isSetup ? (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">机构名称</span>
|
||||
<Input name="organization" autoComplete="organization" required />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{!isLogin ? (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">姓名</span>
|
||||
<Input name="name" autoComplete="name" required />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">邮箱</span>
|
||||
<Input name="email" type="email" autoComplete="email" required />
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">密码</span>
|
||||
<Input name="password" type="password" autoComplete="current-password" required />
|
||||
</label>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full" type="submit" disabled={isPending}>
|
||||
{copy.submit}
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{secondaryAction ? (
|
||||
<div className="mt-5 text-center text-sm text-muted-foreground">
|
||||
<Link className="font-medium text-primary hover:underline" href={secondaryAction.href}>
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthPanel({ mode }: AuthPanelProps): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<main className="min-h-svh bg-background" />}>
|
||||
<AuthPanelContent mode={mode} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
33
modules/auth/components/SignOutButton.tsx
Normal file
33
modules/auth/components/SignOutButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignOutButton(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
setIsPending(true);
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.refresh();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSignOut}
|
||||
disabled={isPending}
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">退出</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
364
modules/elders/components/EldersClient.tsx
Normal file
364
modules/elders/components/EldersClient.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Edit3, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
import type { Elder, ElderInput } from "@/modules/elders/types";
|
||||
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS, GENDER_LABELS } from "@/modules/elders/types";
|
||||
|
||||
type EldersClientProps = {
|
||||
initialElders: Elder[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
const emptyInput: ElderInput = {
|
||||
name: "",
|
||||
gender: "male",
|
||||
age: 75,
|
||||
careLevel: "self-care",
|
||||
room: "",
|
||||
bed: "",
|
||||
status: "active",
|
||||
primaryContact: "",
|
||||
phone: "",
|
||||
medicalNotes: "",
|
||||
};
|
||||
|
||||
function elderToInput(elder: Elder): ElderInput {
|
||||
return {
|
||||
name: elder.name,
|
||||
gender: elder.gender,
|
||||
age: elder.age,
|
||||
careLevel: elder.careLevel,
|
||||
room: elder.room,
|
||||
bed: elder.bed,
|
||||
status: elder.status,
|
||||
primaryContact: elder.primaryContact,
|
||||
phone: elder.phone,
|
||||
medicalNotes: elder.medicalNotes,
|
||||
};
|
||||
}
|
||||
|
||||
export function EldersClient({ initialElders, permissions }: EldersClientProps): React.ReactElement {
|
||||
const [elders, setElders] = useState(initialElders);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ElderInput>(emptyInput);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const canCreate = permissions.includes("elder:create");
|
||||
const canUpdate = permissions.includes("elder:update");
|
||||
const canDelete = permissions.includes("elder:delete");
|
||||
const isEditing = editingId !== null;
|
||||
const canSubmit = isEditing ? canUpdate : canCreate;
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const activeCount = elders.filter((elder) => elder.status === "active").length;
|
||||
const pendingCount = elders.filter((elder) => elder.status === "pending").length;
|
||||
const intensiveCount = elders.filter((elder) => elder.careLevel === "intensive").length;
|
||||
return { activeCount, pendingCount, intensiveCount };
|
||||
}, [elders]);
|
||||
|
||||
function updateField<K extends keyof ElderInput>(key: K, value: ElderInput[K]): void {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function beginCreate(): void {
|
||||
setEditingId(null);
|
||||
setForm(emptyInput);
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
function beginEdit(elder: Elder): void {
|
||||
setEditingId(elder.id);
|
||||
setForm(elderToInput(elder));
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
async function refreshElders(): Promise<void> {
|
||||
const response = await fetch("/api/elders");
|
||||
const result = (await response.json()) as ApiResult<{ elders: Elder[] }>;
|
||||
if (result.success) {
|
||||
setElders(result.elders);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
setMessage("当前角色无权保存老人档案");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
|
||||
const response = await fetch(isEditing ? `/api/elders/${editingId}` : "/api/elders", {
|
||||
method: isEditing ? "PATCH" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
|
||||
setIsPending(false);
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshElders();
|
||||
setEditingId(null);
|
||||
setForm(emptyInput);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
async function handleDelete(elder: Elder): Promise<void> {
|
||||
if (!canDelete) {
|
||||
setMessage("当前角色无权删除老人档案");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
|
||||
const response = await fetch(`/api/elders/${elder.id}`, { method: "DELETE" });
|
||||
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
|
||||
setIsPending(false);
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshElders();
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Badge variant="success">全栈 CRUD</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-normal">老人档案</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">服务端持久化档案、权限校验和审计记录。</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-md border bg-white/72 px-4 py-3">
|
||||
<p className="text-muted-foreground">在院老人</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{metrics.activeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-white/72 px-4 py-3">
|
||||
<p className="text-muted-foreground">待入住</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{metrics.pendingCount}</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-white/72 px-4 py-3">
|
||||
<p className="text-muted-foreground">重点照护</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{metrics.intensiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.82fr_1.18fr]">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{isEditing ? "编辑档案" : "新增档案"}</CardTitle>
|
||||
{isEditing ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={beginCreate}>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
姓名
|
||||
<Input value={form.name} onChange={(event) => updateField("name", event.target.value)} />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
性别
|
||||
<select
|
||||
className="h-11 rounded-md border bg-background px-3 text-sm"
|
||||
value={form.gender}
|
||||
onChange={(event) => updateField("gender", event.target.value as ElderInput["gender"])}
|
||||
>
|
||||
{Object.entries(GENDER_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
年龄
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={130}
|
||||
value={form.age}
|
||||
onChange={(event) => updateField("age", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
护理等级
|
||||
<select
|
||||
className="h-11 rounded-md border bg-background px-3 text-sm"
|
||||
value={form.careLevel}
|
||||
onChange={(event) => updateField("careLevel", event.target.value as ElderInput["careLevel"])}
|
||||
>
|
||||
{Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
状态
|
||||
<select
|
||||
className="h-11 rounded-md border bg-background px-3 text-sm"
|
||||
value={form.status}
|
||||
onChange={(event) => updateField("status", event.target.value as ElderInput["status"])}
|
||||
>
|
||||
{Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
房间
|
||||
<Input value={form.room} onChange={(event) => updateField("room", event.target.value)} />
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
床位
|
||||
<Input value={form.bed} onChange={(event) => updateField("bed", event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
联系人
|
||||
<Input
|
||||
value={form.primaryContact}
|
||||
onChange={(event) => updateField("primaryContact", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
联系电话
|
||||
<Input value={form.phone} onChange={(event) => updateField("phone", event.target.value)} />
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
健康备注
|
||||
<textarea
|
||||
className="min-h-24 rounded-md border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={form.medicalNotes}
|
||||
onChange={(event) => updateField("medicalNotes", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={isPending || !canSubmit}>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{isEditing ? "保存修改" : "新增老人"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>档案列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">姓名</th>
|
||||
<th className="px-4 py-3 font-medium">照护</th>
|
||||
<th className="px-4 py-3 font-medium">床位</th>
|
||||
<th className="px-4 py-3 font-medium">联系人</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{elders.map((elder) => (
|
||||
<tr key={elder.id}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{elder.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{GENDER_LABELS[elder.gender]} · {elder.age} 岁
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{elder.room}-{elder.bed}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{elder.primaryContact} / {elder.phone}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={elder.status === "active" ? "success" : "secondary"}>
|
||||
{ELDER_STATUS_LABELS[elder.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => beginEdit(elder)}
|
||||
disabled={!canUpdate}
|
||||
>
|
||||
<Edit3 className="size-4" aria-hidden="true" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete(elder)}
|
||||
disabled={!canDelete || isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{elders.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无老人档案
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
modules/settings/components/SettingsOverview.tsx
Normal file
176
modules/settings/components/SettingsOverview.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { AuditLog, PublicAccount, RoleDefinition } from "@/modules/core/types";
|
||||
import { ROLE_LABELS } from "@/modules/core/types";
|
||||
|
||||
type SettingsOverviewProps = {
|
||||
accounts: PublicAccount[];
|
||||
roles: RoleDefinition[];
|
||||
auditLogs: AuditLog[];
|
||||
};
|
||||
|
||||
export function SettingsOverview({
|
||||
accounts,
|
||||
roles,
|
||||
auditLogs,
|
||||
}: SettingsOverviewProps): React.ReactElement {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="border-b pb-5">
|
||||
<Badge variant="success">RBAC + Audit</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-normal">权限设置</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">内置角色、账号状态和关键操作审计。</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>系统账号</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{accounts.length}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">服务端持久化账号</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>角色数量</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{roles.length}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">内置权限组</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>审计日志</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{auditLogs.length}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">最近 100 条</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>内置角色</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<div key={role.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">{role.label}</p>
|
||||
<Badge variant="secondary">{role.permissions.length} 权限</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{role.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="outline">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[620px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">账号</th>
|
||||
<th className="px-4 py-3 font-medium">角色</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{accounts.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{ROLE_LABELS[account.role]}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={account.status === "active" ? "success" : "danger"}>
|
||||
{account.status === "active" ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(account.createdAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>审计日志</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">时间</th>
|
||||
<th className="px-4 py-3 font-medium">操作者</th>
|
||||
<th className="px-4 py-3 font-medium">动作</th>
|
||||
<th className="px-4 py-3 font-medium">对象</th>
|
||||
<th className="px-4 py-3 font-medium">结果</th>
|
||||
<th className="px-4 py-3 font-medium">原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="px-4 py-3">{log.actorEmail ?? "系统"}</td>
|
||||
<td className="px-4 py-3">{log.action}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{log.targetType}
|
||||
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={
|
||||
log.result === "success" ? "success" : log.result === "denied" ? "warning" : "danger"
|
||||
}
|
||||
>
|
||||
{log.result}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{log.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
{auditLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无审计日志
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
159
modules/shared/components/AppShell.tsx
Normal file
159
modules/shared/components/AppShell.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bell,
|
||||
BedDouble,
|
||||
ClipboardCheck,
|
||||
HeartPulse,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Stethoscope,
|
||||
TabletSmartphone,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Permission, PublicAccount } from "@/modules/core/types";
|
||||
import { SignOutButton } from "@/modules/auth/components/SignOutButton";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: "/app/dashboard",
|
||||
label: "运营看板",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
href: "/app/elders",
|
||||
label: "老人档案",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: "/app/beds",
|
||||
label: "床位房间",
|
||||
icon: BedDouble,
|
||||
},
|
||||
{
|
||||
href: "/app/health",
|
||||
label: "健康照护",
|
||||
icon: HeartPulse,
|
||||
},
|
||||
{
|
||||
href: "/app/care",
|
||||
label: "护理服务",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
href: "/app/emergency",
|
||||
label: "安全应急",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
{
|
||||
href: "/app/devices",
|
||||
label: "设备运维",
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
href: "/app/notices",
|
||||
label: "公告通知",
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
href: "/app/alerts",
|
||||
label: "规则预警",
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
href: "/app/family",
|
||||
label: "家属服务",
|
||||
icon: TabletSmartphone,
|
||||
},
|
||||
{
|
||||
href: "/app/settings",
|
||||
label: "权限设置",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
account: PublicAccount;
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export function AppShell({ children, account, permissions }: AppShellProps): React.ReactElement {
|
||||
const canCreateElder = permissions.includes("elder:create");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-stretch bg-background text-foreground">
|
||||
<aside className="hidden w-64 shrink-0 border-r bg-white/78 backdrop-blur xl:block">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Link href="/app/dashboard" className="flex min-h-16 items-center gap-3 border-b px-5">
|
||||
<span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Stethoscope className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-base font-semibold">TeaTea 康护</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-4" aria-label="主导航">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="group flex min-h-11 items-center gap-3 rounded-md px-3 text-sm transition-colors hover:bg-secondary"
|
||||
>
|
||||
<item.icon
|
||||
className="size-4 shrink-0 text-muted-foreground group-hover:text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium">{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-20 flex min-h-14 items-center justify-between border-b bg-background/88 px-4 backdrop-blur md:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground xl:hidden">
|
||||
<Stethoscope className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-sm font-medium">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||
</div>
|
||||
<Button size="sm" disabled={!canCreateElder}>
|
||||
<Home className="size-4" aria-hidden="true" />
|
||||
入住
|
||||
</Button>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 md:px-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { navItems };
|
||||
Reference in New Issue
Block a user