feat: wire auth and CRUD UI to APIs
This commit is contained in:
15
app/(app)/app/elders/page.tsx
Normal file
15
app/(app)/app/elders/page.tsx
Normal 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
26
app/(app)/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/(app)/app/settings/page.tsx
Normal file
26
app/(app)/app/settings/page.tsx
Normal 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
81
app/globals.css
Normal 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);
|
||||||
|
}
|
||||||
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