From 86a8f547257daf82cf975e113993dd7abe49cd5e Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Wed, 1 Jul 2026 06:29:18 -0700 Subject: [PATCH] feat: wire auth and CRUD UI to APIs --- app/(app)/app/elders/page.tsx | 15 + app/(app)/app/layout.tsx | 26 ++ app/(app)/app/settings/page.tsx | 26 ++ app/globals.css | 81 ++++ modules/auth/components/AuthGate.tsx | 7 + modules/auth/components/AuthPanel.tsx | 232 +++++++++++ modules/auth/components/SignOutButton.tsx | 33 ++ modules/elders/components/EldersClient.tsx | 364 ++++++++++++++++++ .../settings/components/SettingsOverview.tsx | 176 +++++++++ modules/shared/components/AppShell.tsx | 159 ++++++++ 10 files changed, 1119 insertions(+) create mode 100644 app/(app)/app/elders/page.tsx create mode 100644 app/(app)/app/layout.tsx create mode 100644 app/(app)/app/settings/page.tsx create mode 100644 app/globals.css create mode 100644 modules/auth/components/AuthGate.tsx create mode 100644 modules/auth/components/AuthPanel.tsx create mode 100644 modules/auth/components/SignOutButton.tsx create mode 100644 modules/elders/components/EldersClient.tsx create mode 100644 modules/settings/components/SettingsOverview.tsx create mode 100644 modules/shared/components/AppShell.tsx diff --git a/app/(app)/app/elders/page.tsx b/app/(app)/app/elders/page.tsx new file mode 100644 index 0000000..06c4017 --- /dev/null +++ b/app/(app)/app/elders/page.tsx @@ -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 { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + const data = await readData(); + return ; +} diff --git a/app/(app)/app/layout.tsx b/app/(app)/app/layout.tsx new file mode 100644 index 0000000..069d487 --- /dev/null +++ b/app/(app)/app/layout.tsx @@ -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 { + const bootstrap = await getBootstrapState(); + if (bootstrap.setupRequired) { + redirect("/setup"); + } + + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + return ( + + {children} + + ); +} diff --git a/app/(app)/app/settings/page.tsx b/app/(app)/app/settings/page.tsx new file mode 100644 index 0000000..d0cecaa --- /dev/null +++ b/app/(app)/app/settings/page.tsx @@ -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 { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + if (!hasPermission(context.account.role, "account:read")) { + redirect("/app/dashboard"); + } + + const data = await readData(); + return ( + toPublicAccount(account))} + roles={ROLE_DEFINITIONS} + auditLogs={data.auditLogs.slice(0, 100)} + /> + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c7f1a28 --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/modules/auth/components/AuthGate.tsx b/modules/auth/components/AuthGate.tsx new file mode 100644 index 0000000..9615f6b --- /dev/null +++ b/modules/auth/components/AuthGate.tsx @@ -0,0 +1,7 @@ +type AuthGateProps = { + children: React.ReactNode; +}; + +export function AuthGate({ children }: AuthGateProps): React.ReactElement { + return <>{children}; +} diff --git a/modules/auth/components/AuthPanel.tsx b/modules/auth/components/AuthPanel.tsx new file mode 100644 index 0000000..230ac86 --- /dev/null +++ b/modules/auth/components/AuthPanel.tsx @@ -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 = { + 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(null); + + const copy = modeCopy[mode]; + const isSetup = mode === "setup"; + const isLogin = mode === "login"; + + useEffect(() => { + let isMounted = true; + + async function loadState(): Promise { + 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): Promise { + 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
; + } + + return ( +
+
+ + + + TeaTea 康护 + + +
+ {copy.badge} +

{copy.title}

+

+ 简洁、稳定、面向机构照护。 +

+
+ +
+ 档案 + 护理 + 预警 +
+
+ +
+
+
+ +
+ + +
+
+ {isSetup ? ( +
+

{copy.title}

+
+ +
+ {isSetup ? ( + + ) : null} + + {!isLogin ? ( + + ) : null} + + + + + + {message ? ( +

+ {message} +

+ ) : null} + + +
+ + {secondaryAction ? ( +
+ + {secondaryAction.label} + +
+ ) : null} +
+
+
+
+ ); +} + +export function AuthPanel({ mode }: AuthPanelProps): React.ReactElement { + return ( + }> + + + ); +} diff --git a/modules/auth/components/SignOutButton.tsx b/modules/auth/components/SignOutButton.tsx new file mode 100644 index 0000000..e0d5c26 --- /dev/null +++ b/modules/auth/components/SignOutButton.tsx @@ -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 { + setIsPending(true); + await fetch("/api/auth/logout", { method: "POST" }); + router.refresh(); + router.replace("/login"); + } + + return ( + + ); +} diff --git a/modules/elders/components/EldersClient.tsx b/modules/elders/components/EldersClient.tsx new file mode 100644 index 0000000..65b3687 --- /dev/null +++ b/modules/elders/components/EldersClient.tsx @@ -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(null); + const [form, setForm] = useState(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(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 { + 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): Promise { + 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 { + 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 ( +
+
+
+ 全栈 CRUD +

老人档案

+

服务端持久化档案、权限校验和审计记录。

+
+
+
+

在院老人

+

{metrics.activeCount}

+
+
+

待入住

+

{metrics.pendingCount}

+
+
+

重点照护

+

{metrics.intensiveCount}

+
+
+
+ +
+ + + {isEditing ? "编辑档案" : "新增档案"} + {isEditing ? ( + + ) : null} + + +
+ +
+ + +
+
+ + +
+
+ + +
+ + +