feat: wire auth and CRUD UI to APIs

This commit is contained in:
2026-07-01 06:29:18 -07:00
parent 914f74c5cc
commit 86a8f54725
10 changed files with 1119 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { readData } from "@/modules/core/server/store";
import { EldersClient } from "@/modules/elders/components/EldersClient";
export default async function EldersPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
const data = await readData();
return <EldersClient initialElders={data.elders} permissions={context.permissions} />;
}

26
app/(app)/app/layout.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getBootstrapState, getCurrentAuthContext } from "@/modules/core/server/auth";
import { AppShell } from "@/modules/shared/components/AppShell";
type AppLayoutProps = {
children: React.ReactNode;
};
export default async function AppLayout({ children }: AppLayoutProps): Promise<React.ReactElement> {
const bootstrap = await getBootstrapState();
if (bootstrap.setupRequired) {
redirect("/setup");
}
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
return (
<AppShell account={context.account} permissions={context.permissions}>
{children}
</AppShell>
);
}

View File

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
import { hasPermission, ROLE_DEFINITIONS } from "@/modules/core/server/permissions";
import { readData } from "@/modules/core/server/store";
import { SettingsOverview } from "@/modules/settings/components/SettingsOverview";
export default async function SettingsPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.account.role, "account:read")) {
redirect("/app/dashboard");
}
const data = await readData();
return (
<SettingsOverview
accounts={data.accounts.map((account) => toPublicAccount(account))}
roles={ROLE_DEFINITIONS}
auditLogs={data.auditLogs.slice(0, 100)}
/>
);
}

81
app/globals.css Normal file
View File

@@ -0,0 +1,81 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.986 0.008 151);
--foreground: oklch(0.215 0.025 157);
--card: oklch(1 0 0);
--card-foreground: oklch(0.215 0.025 157);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.215 0.025 157);
--primary: oklch(0.48 0.11 155);
--primary-foreground: oklch(0.985 0.01 150);
--secondary: oklch(0.945 0.026 152);
--secondary-foreground: oklch(0.28 0.058 156);
--muted: oklch(0.95 0.012 151);
--muted-foreground: oklch(0.47 0.025 157);
--accent: oklch(0.88 0.06 150);
--accent-foreground: oklch(0.25 0.055 155);
--destructive: oklch(0.58 0.19 25);
--destructive-foreground: oklch(0.985 0.01 150);
--border: oklch(0.89 0.016 151);
--input: oklch(0.89 0.016 151);
--ring: oklch(0.54 0.11 155);
}
* {
border-color: var(--border);
}
html {
background: var(--background);
color: var(--foreground);
}
body {
min-height: 100svh;
background:
radial-gradient(circle at 12% 8%, color-mix(in oklch, var(--accent) 36%, transparent), transparent 30%),
linear-gradient(180deg, color-mix(in oklch, var(--background) 90%, white), var(--background));
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
letter-spacing: 0;
}
button,
a,
[role="button"] {
-webkit-tap-highlight-color: transparent;
}
::selection {
background: color-mix(in oklch, var(--primary) 22%, transparent);
}

View File

@@ -0,0 +1,7 @@
type AuthGateProps = {
children: React.ReactNode;
};
export function AuthGate({ children }: AuthGateProps): React.ReactElement {
return <>{children}</>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };