630 lines
23 KiB
TypeScript
630 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useMemo, useState } from "react";
|
||
import { Brain, Edit3, Filter, Plus, Search, Trash2 } from "lucide-react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Dialog } from "@/components/ui/dialog";
|
||
import { Input, Textarea } from "@/components/ui/input";
|
||
import { Select } from "@/components/ui/select";
|
||
import { Table } from "@/components/ui/table";
|
||
import { ElderAiAnalysisDialog } from "@/modules/ai/components/ElderAiAnalysisDialog";
|
||
import type { ApiResult } from "@/modules/core/server/api";
|
||
import type { Permission } from "@/modules/core/types";
|
||
import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/types";
|
||
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS, GENDER_LABELS } from "@/modules/elders/types";
|
||
import type { ElderOperationalContext, OperationalContextLine } from "@/modules/operations/types";
|
||
|
||
type EldersClientProps = {
|
||
elderContexts?: Record<string, ElderOperationalContext>;
|
||
initialElders: Elder[];
|
||
permissions: Permission[];
|
||
};
|
||
|
||
type ElderFormState = Omit<ElderInput, "age" | "careLevel" | "gender" | "status"> & {
|
||
age: string;
|
||
careLevel: CareLevel | "";
|
||
gender: ElderInput["gender"] | "";
|
||
status: ElderStatus | "";
|
||
};
|
||
|
||
const emptyInput: ElderFormState = {
|
||
name: "",
|
||
gender: "",
|
||
age: "",
|
||
careLevel: "",
|
||
room: "",
|
||
bed: "",
|
||
status: "",
|
||
primaryContact: "",
|
||
phone: "",
|
||
medicalNotes: "",
|
||
};
|
||
|
||
const PAGE_SIZE = 8;
|
||
const ALL_STATUS = "all";
|
||
const ALL_CARE_LEVELS = "all";
|
||
|
||
function elderToInput(elder: Elder): ElderFormState {
|
||
return {
|
||
name: elder.name,
|
||
gender: elder.gender,
|
||
age: String(elder.age),
|
||
careLevel: elder.careLevel,
|
||
room: elder.room,
|
||
bed: elder.bed,
|
||
status: elder.status,
|
||
primaryContact: elder.primaryContact,
|
||
phone: elder.phone,
|
||
medicalNotes: elder.medicalNotes,
|
||
};
|
||
}
|
||
|
||
function formatDateTime(value: string): string {
|
||
return new Date(value).toLocaleString("zh-CN");
|
||
}
|
||
|
||
function formatBedLabel(elder: Pick<Elder, "room" | "bed">): string {
|
||
return elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "-";
|
||
}
|
||
|
||
const statusOptions = [
|
||
{ value: ALL_STATUS, label: "全部状态" },
|
||
...Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||
];
|
||
|
||
const careLevelOptions = [
|
||
{ value: ALL_CARE_LEVELS, label: "全部照护" },
|
||
...Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => ({ value, label })),
|
||
];
|
||
|
||
const genderOptions = [
|
||
{ value: "", label: "选择性别", disabled: true },
|
||
...Object.entries(GENDER_LABELS).map(([value, label]) => ({ value, label })),
|
||
];
|
||
const formCareLevelOptions = [
|
||
{ value: "", label: "选择护理等级", disabled: true },
|
||
...Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => ({ value, label })),
|
||
];
|
||
const formStatusOptions = [
|
||
{ value: "", label: "选择档案状态", disabled: true },
|
||
...Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||
];
|
||
|
||
function matchesFilters(
|
||
elder: Elder,
|
||
query: string,
|
||
statusFilter: ElderStatus | typeof ALL_STATUS,
|
||
careLevelFilter: CareLevel | typeof ALL_CARE_LEVELS,
|
||
): boolean {
|
||
const normalizedQuery = query.trim().toLowerCase();
|
||
const matchesSearch =
|
||
normalizedQuery.length === 0 ||
|
||
[elder.name, elder.room, elder.bed, elder.primaryContact, elder.phone, elder.medicalNotes]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(normalizedQuery);
|
||
const matchesStatus = statusFilter === ALL_STATUS || elder.status === statusFilter;
|
||
const matchesCareLevel = careLevelFilter === ALL_CARE_LEVELS || elder.careLevel === careLevelFilter;
|
||
|
||
return matchesSearch && matchesStatus && matchesCareLevel;
|
||
}
|
||
|
||
export function EldersClient({ elderContexts = {}, initialElders, permissions }: EldersClientProps): React.ReactElement {
|
||
const [elders, setElders] = useState(initialElders);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [form, setForm] = useState<ElderFormState>(emptyInput);
|
||
const [message, setMessage] = useState("");
|
||
const [isPending, setIsPending] = useState(false);
|
||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<Elder | null>(null);
|
||
const [query, setQuery] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState<ElderStatus | typeof ALL_STATUS>(ALL_STATUS);
|
||
const [careLevelFilter, setCareLevelFilter] = useState<CareLevel | typeof ALL_CARE_LEVELS>(ALL_CARE_LEVELS);
|
||
const [page, setPage] = useState(1);
|
||
const [aiTarget, setAiTarget] = useState<Elder | null>(null);
|
||
|
||
const canCreate = permissions.includes("elder:create");
|
||
const canUpdate = permissions.includes("elder:update");
|
||
const canDelete = permissions.includes("elder:delete");
|
||
const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
|
||
const isEditing = editingId !== null;
|
||
const canSubmit = isEditing ? canUpdate : canCreate;
|
||
|
||
const metrics = useMemo(() => {
|
||
const totalCount = elders.length;
|
||
const activeCount = elders.filter((elder) => elder.status === "active").length;
|
||
const pendingCount = elders.filter((elder) => elder.status === "pending").length;
|
||
const dischargedCount = elders.filter((elder) => elder.status === "discharged").length;
|
||
return { activeCount, dischargedCount, pendingCount, totalCount };
|
||
}, [elders]);
|
||
|
||
const filteredElders = useMemo(
|
||
() => elders.filter((elder) => matchesFilters(elder, query, statusFilter, careLevelFilter)),
|
||
[careLevelFilter, elders, query, statusFilter],
|
||
);
|
||
const pageCount = Math.max(1, Math.ceil(filteredElders.length / PAGE_SIZE));
|
||
const safePage = Math.min(page, pageCount);
|
||
const pageStart = (safePage - 1) * PAGE_SIZE;
|
||
const visibleElders = filteredElders.slice(pageStart, pageStart + PAGE_SIZE);
|
||
const editingElder = editingId ? elders.find((elder) => elder.id === editingId) : undefined;
|
||
|
||
function updateField<K extends keyof ElderFormState>(key: K, value: ElderFormState[K]): void {
|
||
setForm((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
function beginCreate(): void {
|
||
setEditingId(null);
|
||
setForm(emptyInput);
|
||
setMessage("");
|
||
setIsFormOpen(true);
|
||
}
|
||
|
||
function beginEdit(elder: Elder): void {
|
||
setEditingId(elder.id);
|
||
setForm(elderToInput(elder));
|
||
setMessage("");
|
||
setIsFormOpen(true);
|
||
}
|
||
|
||
function closeForm(): void {
|
||
if (isPending) {
|
||
return;
|
||
}
|
||
|
||
setIsFormOpen(false);
|
||
setEditingId(null);
|
||
setForm(emptyInput);
|
||
}
|
||
|
||
function updateQuery(value: string): void {
|
||
setQuery(value);
|
||
setPage(1);
|
||
}
|
||
|
||
function updateStatusFilter(value: string): void {
|
||
setStatusFilter(value as ElderStatus | typeof ALL_STATUS);
|
||
setPage(1);
|
||
}
|
||
|
||
function updateCareLevelFilter(value: string): void {
|
||
setCareLevelFilter(value as CareLevel | typeof ALL_CARE_LEVELS);
|
||
setPage(1);
|
||
}
|
||
|
||
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("");
|
||
|
||
if (!form.gender || !form.careLevel || !form.status) {
|
||
setIsPending(false);
|
||
setMessage("请完整选择性别、护理等级和档案状态");
|
||
return;
|
||
}
|
||
|
||
const numericAge = Number(form.age);
|
||
if (!Number.isInteger(numericAge) || numericAge < 0 || numericAge > 130) {
|
||
setIsPending(false);
|
||
setMessage("年龄需为0到130之间的整数");
|
||
return;
|
||
}
|
||
|
||
const payload: ElderInput = {
|
||
...form,
|
||
age: numericAge,
|
||
careLevel: form.careLevel,
|
||
gender: form.gender,
|
||
status: form.status,
|
||
};
|
||
|
||
const response = await fetch(isEditing ? `/api/elders/${editingId}` : "/api/elders", {
|
||
method: isEditing ? "PATCH" : "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
|
||
setIsPending(false);
|
||
|
||
if (!result.success) {
|
||
setMessage(result.reason);
|
||
return;
|
||
}
|
||
|
||
await refreshElders();
|
||
setEditingId(null);
|
||
setForm(emptyInput);
|
||
setIsFormOpen(false);
|
||
setMessage(result.reason);
|
||
}
|
||
|
||
async function handleDelete(): Promise<void> {
|
||
if (!deleteTarget) {
|
||
return;
|
||
}
|
||
|
||
if (!canDelete) {
|
||
setMessage("当前角色无权删除老人档案");
|
||
return;
|
||
}
|
||
|
||
setIsPending(true);
|
||
setMessage("");
|
||
|
||
const response = await fetch(`/api/elders/${deleteTarget.id}`, { method: "DELETE" });
|
||
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
|
||
setIsPending(false);
|
||
|
||
if (!result.success) {
|
||
setMessage(result.reason);
|
||
return;
|
||
}
|
||
|
||
await refreshElders();
|
||
setDeleteTarget(null);
|
||
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>
|
||
<h1 className="text-3xl font-semibold tracking-normal">老人档案</h1>
|
||
<p className="mt-2 text-sm text-muted-foreground">列表管理、筛选搜索、分页、弹窗维护和审计持久化。</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button disabled={!canCreate} onClick={beginCreate} type="button">
|
||
<Plus className="size-4" aria-hidden="true" />
|
||
新增老人
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<Card>
|
||
<CardHeader className="p-4">
|
||
<CardDescription>档案总数</CardDescription>
|
||
<CardTitle className="text-3xl">{metrics.totalCount}</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="p-4">
|
||
<CardDescription>在院</CardDescription>
|
||
<CardTitle className="text-3xl">{metrics.activeCount}</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="p-4">
|
||
<CardDescription>待入住</CardDescription>
|
||
<CardTitle className="text-3xl">{metrics.pendingCount}</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="p-4">
|
||
<CardDescription>已退住</CardDescription>
|
||
<CardTitle className="text-3xl">{metrics.dischargedCount}</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
</section>
|
||
|
||
<Card>
|
||
<CardHeader className="gap-4">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<CardTitle>档案列表</CardTitle>
|
||
<CardDescription className="mt-1">
|
||
共 {filteredElders.length} 条匹配记录,按状态、照护等级和关键词快速定位。
|
||
</CardDescription>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_150px_150px]">
|
||
<label className="relative block">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
aria-label="搜索老人档案"
|
||
className="pl-9"
|
||
onChange={(event) => updateQuery(event.target.value)}
|
||
placeholder="搜索姓名、床位、联系人"
|
||
value={query}
|
||
/>
|
||
</label>
|
||
<div className="relative block">
|
||
<Filter className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Select
|
||
aria-label="按老人状态筛选"
|
||
className="w-full pl-9"
|
||
onValueChange={updateStatusFilter}
|
||
options={statusOptions}
|
||
value={statusFilter}
|
||
/>
|
||
</div>
|
||
<Select
|
||
aria-label="按照护等级筛选"
|
||
className="w-full"
|
||
onValueChange={updateCareLevelFilter}
|
||
options={careLevelOptions}
|
||
value={careLevelFilter}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{message ? (
|
||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||
{message}
|
||
</p>
|
||
) : null}
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto rounded-md border">
|
||
<Table className="w-full min-w-[980px] text-sm">
|
||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||
<Table.Row>
|
||
<Table.Head className="px-4 py-3 font-medium">姓名</Table.Head>
|
||
<Table.Head className="px-4 py-3 font-medium">照护</Table.Head>
|
||
<Table.Head className="px-4 py-3 font-medium">床位</Table.Head>
|
||
<Table.Head className="px-4 py-3 font-medium">近期联动</Table.Head>
|
||
<Table.Head className="px-4 py-3 font-medium">联系人</Table.Head>
|
||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||
</Table.Row>
|
||
</Table.Header>
|
||
<Table.Body className="divide-y bg-card">
|
||
{visibleElders.map((elder) => (
|
||
<Table.Row key={elder.id}>
|
||
<Table.Cell 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>
|
||
</Table.Cell>
|
||
<Table.Cell className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</Table.Cell>
|
||
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatBedLabel(elder)}</Table.Cell>
|
||
<Table.Cell className="px-4 py-3">
|
||
<ContextLines lines={elderContexts[elder.id]?.lines ?? []} />
|
||
</Table.Cell>
|
||
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||
{elder.primaryContact} / {elder.phone}
|
||
</Table.Cell>
|
||
<Table.Cell className="px-4 py-3">
|
||
<Badge variant={elder.status === "active" ? "success" : "secondary"}>
|
||
{ELDER_STATUS_LABELS[elder.status]}
|
||
</Badge>
|
||
</Table.Cell>
|
||
<Table.Cell className="px-4 py-3">
|
||
<div className="flex justify-end gap-2">
|
||
{canUseAi ? (
|
||
<Button
|
||
onClick={() => setAiTarget(elder)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Brain className="size-4" aria-hidden="true" />
|
||
智能分析
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
disabled={!canUpdate}
|
||
onClick={() => beginEdit(elder)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Edit3 className="size-4" aria-hidden="true" />
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
disabled={!canDelete || isPending}
|
||
onClick={() => setDeleteTarget(elder)}
|
||
size="sm"
|
||
type="button"
|
||
variant="destructive"
|
||
>
|
||
<Trash2 className="size-4" aria-hidden="true" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
))}
|
||
{visibleElders.length === 0 ? (
|
||
<Table.Row>
|
||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||
暂无匹配老人档案
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
) : null}
|
||
</Table.Body>
|
||
</Table>
|
||
</div>
|
||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm text-muted-foreground">
|
||
第 {safePage} / {pageCount} 页
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
disabled={safePage === 1}
|
||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
disabled={safePage === pageCount}
|
||
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog
|
||
description="保存后会写入 PostgreSQL,并生成审计记录。"
|
||
onClose={closeForm}
|
||
open={isFormOpen}
|
||
title={isEditing ? "编辑老人档案" : "新增老人档案"}
|
||
>
|
||
<form className="grid gap-3" id="elder-form" 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">
|
||
<div className="grid gap-1.5 text-sm font-medium">
|
||
性别
|
||
<Select
|
||
aria-label="性别"
|
||
onValueChange={(value) => updateField("gender", value as ElderFormState["gender"])}
|
||
options={genderOptions}
|
||
required
|
||
value={form.gender}
|
||
/>
|
||
</div>
|
||
<label className="grid gap-1.5 text-sm font-medium">
|
||
年龄
|
||
<Input
|
||
max={130}
|
||
min={0}
|
||
onChange={(event) => updateField("age", event.target.value)}
|
||
required
|
||
type="number"
|
||
value={form.age}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div className="grid gap-1.5 text-sm font-medium">
|
||
护理等级
|
||
<Select
|
||
aria-label="护理等级"
|
||
onValueChange={(value) => updateField("careLevel", value as ElderFormState["careLevel"])}
|
||
options={formCareLevelOptions}
|
||
required
|
||
value={form.careLevel}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-1.5 text-sm font-medium">
|
||
状态
|
||
<Select
|
||
aria-label="状态"
|
||
onValueChange={(value) => updateField("status", value as ElderFormState["status"])}
|
||
options={formStatusOptions}
|
||
required
|
||
value={form.status}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{isEditing ? (
|
||
<div className="rounded-md border bg-secondary/30 px-3 py-2 text-sm">
|
||
当前床位:{editingElder ? formatBedLabel(editingElder) : "-"}
|
||
</div>
|
||
) : null}
|
||
<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"
|
||
onChange={(event) => updateField("medicalNotes", event.target.value)}
|
||
value={form.medicalNotes}
|
||
/>
|
||
</label>
|
||
{message ? (
|
||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||
{message}
|
||
</p>
|
||
) : null}
|
||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||
<Button disabled={isPending} onClick={closeForm} type="button" variant="outline">
|
||
取消
|
||
</Button>
|
||
<Button disabled={isPending || !canSubmit} type="submit">
|
||
<Plus className="size-4" aria-hidden="true" />
|
||
{isEditing ? "保存修改" : "新增老人"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
description={deleteTarget ? `确认删除 ${deleteTarget.name} 的档案?该操作会写入审计日志。` : undefined}
|
||
onClose={() => setDeleteTarget(null)}
|
||
open={deleteTarget !== null}
|
||
title="删除确认"
|
||
width="sm"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{deleteTarget ? (
|
||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||
{deleteTarget.name},{formatBedLabel(deleteTarget)},最近更新:
|
||
{formatDateTime(deleteTarget.updatedAt)}
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
|
||
取消
|
||
</Button>
|
||
<Button disabled={isPending || !canDelete} onClick={() => void handleDelete()} type="button" variant="destructive">
|
||
删除档案
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
|
||
<ElderAiAnalysisDialog elder={aiTarget} onClose={() => setAiTarget(null)} open={aiTarget !== null} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function contextToneClass(tone: OperationalContextLine["tone"]): string {
|
||
if (tone === "danger") {
|
||
return "text-destructive";
|
||
}
|
||
if (tone === "warning") {
|
||
return "text-amber-700";
|
||
}
|
||
return "text-muted-foreground";
|
||
}
|
||
|
||
function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement {
|
||
if (lines.length === 0) {
|
||
return <span className="text-muted-foreground">-</span>;
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-1">
|
||
{lines.map((line) => (
|
||
<p className={`max-w-[240px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
|
||
<span className="font-medium">{line.label}</span> {line.value}
|
||
</p>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|