Files
teatea-pension/modules/alerts/components/AlertsWorkspaceClient.tsx

184 lines
15 KiB
TypeScript

"use client";
import { FormEvent, useMemo, useState } from "react";
import { Radio, Search, ShieldAlert, Siren } 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 { 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 type { IncidentSeverity } from "@/modules/core/types";
import type { ApiResult } from "@/modules/core/server/api";
import { INCIDENT_SEVERITY_LABELS } from "@/modules/emergency/types";
import type { AlertCenterData, AlertRule, AlertRuleInput, AlertRuleStatus, AlertRuleType, AlertTrigger, AlertTriggerInput, AlertTriggerStatus } from "@/modules/alerts/types";
import { ALERT_RULE_STATUS_LABELS, ALERT_RULE_STATUS_VALUES, ALERT_RULE_TYPE_LABELS, ALERT_RULE_TYPE_VALUES, ALERT_TRIGGER_STATUS_LABELS, ALERT_TRIGGER_STATUS_VALUES } from "@/modules/alerts/types";
type Props = { canManage: boolean; initialData: AlertCenterData };
type DialogState =
| { kind: "rule"; mode: "create"; value: AlertRuleInput }
| { kind: "rule"; mode: "edit"; id: string; value: AlertRuleInput }
| { kind: "trigger"; mode: "create"; value: AlertTriggerInput }
| { kind: "trigger"; mode: "edit"; id: string; value: AlertTriggerInput };
const severityValues = ["info", "warning", "critical"] as const satisfies readonly IncidentSeverity[];
const emptyRule: AlertRuleInput = { name: "", ruleType: "health", severity: "warning", status: "enabled", conditionSummary: "", suggestion: "" };
const emptyTrigger: AlertTriggerInput = { ruleId: undefined, elderId: undefined, title: "", description: "", status: "open", source: "", handlingNotes: "" };
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function ruleToInput(rule: AlertRule): AlertRuleInput {
return { name: rule.name, ruleType: rule.ruleType, severity: rule.severity, status: rule.status, conditionSummary: rule.conditionSummary, suggestion: rule.suggestion };
}
function triggerToInput(trigger: AlertTrigger): AlertTriggerInput {
return { ruleId: trigger.ruleId, elderId: trigger.elderId, title: trigger.title, description: trigger.description, status: trigger.status, source: trigger.source, handlingNotes: trigger.handlingNotes };
}
function badgeVariant(value: string): "danger" | "secondary" | "success" | "warning" {
if (value === "critical") return "danger";
if (value === "enabled" || value === "resolved" || value === "closed") return "success";
if (value === "open" || value === "acknowledged" || value === "warning") return "warning";
return "secondary";
}
export function AlertsWorkspaceClient({ canManage, initialData }: Props): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [triggerStatus, setTriggerStatus] = useState<"all" | AlertTriggerStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredRules = useMemo(() => data.rules.filter((rule) => !normalizedQuery || [rule.name, rule.conditionSummary, rule.suggestion].join(" ").toLowerCase().includes(normalizedQuery)), [data.rules, normalizedQuery]);
const filteredTriggers = useMemo(
() =>
data.triggers.filter((trigger) => {
const matchesQuery = !normalizedQuery || [trigger.title, trigger.description, trigger.ruleName, trigger.elderName, trigger.source].join(" ").toLowerCase().includes(normalizedQuery);
return matchesQuery && (triggerStatus === "all" || trigger.status === triggerStatus);
}),
[data.triggers, normalizedQuery, triggerStatus],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/alerts/rules");
const result = (await response.json()) as ApiResult<{ data: AlertCenterData }>;
if (result.success) setData(result.data);
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护规则预警");
return;
}
const isCreate = dialog.mode === "create";
const path = dialog.kind === "rule" ? (isCreate ? "/api/alerts/rules" : `/api/alerts/rules/${dialog.id}`) : isCreate ? "/api/alerts/triggers" : `/api/alerts/triggers/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, { method: isCreate ? "POST" : "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(dialog.value) });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "rule" | "trigger", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除规则预警记录");
return;
}
setIsPending(true);
const response = await fetch(kind === "rule" ? `/api/alerts/rules/${id}` : `/api/alerts/triggers/${id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) await refreshData();
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Radio} label="启用规则" value={data.metrics.enabledRules} />
<MetricCard icon={ShieldAlert} label="待处理触发" value={data.metrics.openTriggers} />
<MetricCard icon={Siren} label="紧急触发" value={data.metrics.criticalTriggers} />
<MetricCard icon={Radio} label="已处理触发" value={data.metrics.resolvedTriggers} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl: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="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
<label className="relative block sm:col-span-2 xl:col-span-1"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input className="w-full pl-9 xl:w-72" onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则或触发记录" value={query} /></label>
<Select aria-label="触发状态" onValueChange={(value) => setTriggerStatus(value as "all" | AlertTriggerStatus)} options={[{ value: "all", label: "全部触发状态" }, ...ALERT_TRIGGER_STATUS_VALUES.map((value) => ({ value, label: ALERT_TRIGGER_STATUS_LABELS[value] }))]} value={triggerStatus} />
<Button disabled={!canManage} onClick={() => setDialog({ kind: "rule", mode: "create", value: emptyRule })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "trigger", mode: "create", value: emptyTrigger })} type="button" variant="outline"></Button>
</div>
</section>
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
<DataTable title="预警规则" empty="暂无预警规则" colSpan={6} heads={["规则", "类型", "级别", "状态", "更新时间", "操作"]}>
{filteredRules.map((rule) => (
<Table.Row key={rule.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{rule.name}</p><p className="text-xs text-muted-foreground">{rule.conditionSummary || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ALERT_RULE_TYPE_LABELS[rule.ruleType]}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(rule.severity)}>{INCIDENT_SEVERITY_LABELS[rule.severity]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(rule.status)}>{ALERT_RULE_STATUS_LABELS[rule.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(rule.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("rule", rule.id)} onEdit={() => setDialog({ kind: "rule", mode: "edit", id: rule.id, value: ruleToInput(rule) })} /></Table.Cell>
</Table.Row>
))}
{filteredRules.length === 0 ? <EmptyRow colSpan={6} text="暂无预警规则" /> : null}
</DataTable>
<DataTable title="触发记录" empty="暂无触发记录" colSpan={7} heads={["记录", "规则", "老人", "状态", "来源", "更新时间", "操作"]}>
{filteredTriggers.map((trigger) => (
<Table.Row key={trigger.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{trigger.title}</p><p className="text-xs text-muted-foreground">{trigger.description || trigger.handlingNotes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.ruleName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.elderName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(trigger.status)}>{ALERT_TRIGGER_STATUS_LABELS[trigger.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.source || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(trigger.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("trigger", trigger.id)} onEdit={() => setDialog({ kind: "trigger", mode: "edit", id: trigger.id, value: triggerToInput(trigger) })} /></Table.Cell>
</Table.Row>
))}
{filteredTriggers.length === 0 ? <EmptyRow colSpan={7} text="暂无触发记录" /> : null}
</DataTable>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "rule" ? "预警规则" : "触发记录"} width="lg">
{dialog ? <form className="grid gap-3" onSubmit={submitDialog}>{dialog.kind === "rule" ? <RuleForm dialog={dialog} setDialog={setDialog} /> : <TriggerForm dialog={dialog} rules={data.rules} setDialog={setDialog} />}<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"><Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button><Button disabled={isPending || !canManage} type="submit"></Button></div></form> : null}
</Dialog>
</div>
);
}
function RuleForm({ dialog, setDialog }: { dialog: Extract<DialogState, { kind: "rule" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
const update = (next: Partial<AlertRuleInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Input label="规则名称" onChange={(event) => update({ name: event.target.value })} required value={value.name} /><Select aria-label="规则类型" onValueChange={(ruleType) => update({ ruleType: ruleType as AlertRuleType })} options={ALERT_RULE_TYPE_VALUES.map((item) => ({ value: item, label: ALERT_RULE_TYPE_LABELS[item] }))} value={value.ruleType} /><Select aria-label="级别" onValueChange={(severity) => update({ severity: severity as IncidentSeverity })} options={severityValues.map((item) => ({ value: item, label: INCIDENT_SEVERITY_LABELS[item] }))} value={value.severity} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as AlertRuleStatus })} options={ALERT_RULE_STATUS_VALUES.map((item) => ({ value: item, label: ALERT_RULE_STATUS_LABELS[item] }))} value={value.status} /><Textarea label="条件摘要" onChange={(event) => update({ conditionSummary: event.target.value })} value={value.conditionSummary} /><Textarea label="处理建议" onChange={(event) => update({ suggestion: event.target.value })} value={value.suggestion} /></>;
}
function TriggerForm({ dialog, rules, setDialog }: { dialog: Extract<DialogState, { kind: "trigger" }>; rules: AlertRule[]; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
const update = (next: Partial<AlertTriggerInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Select aria-label="关联规则" onValueChange={(ruleId) => update({ ruleId: ruleId || undefined })} options={[{ value: "", label: "不关联规则" }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} value={value.ruleId ?? ""} /><Input label="标题" onChange={(event) => update({ title: event.target.value })} required value={value.title} /><Textarea label="描述" onChange={(event) => update({ description: event.target.value })} value={value.description} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as AlertTriggerStatus })} options={ALERT_TRIGGER_STATUS_VALUES.map((item) => ({ value: item, label: ALERT_TRIGGER_STATUS_LABELS[item] }))} value={value.status} /><Input label="来源" onChange={(event) => update({ source: event.target.value })} value={value.source} /><Textarea label="处理记录" onChange={(event) => update({ handlingNotes: event.target.value })} value={value.handlingNotes} /></>;
}
function MetricCard({ icon: Icon, label, value }: { icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; label: string; value: number }): React.ReactElement {
return <Card><CardHeader className="flex flex-row items-center justify-between p-4"><div><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div><Icon className="size-5 text-primary" aria-hidden={true} /></CardHeader></Card>;
}
function DataTable({ children, heads, title }: { children: React.ReactNode; colSpan: number; empty: string; heads: string[]; title: string }): React.ReactElement {
return <Card><CardHeader><CardTitle>{title}</CardTitle></CardHeader><CardContent className="overflow-x-auto"><Table className="w-full min-w-[980px] text-sm"><Table.Header className="bg-secondary text-left text-xs text-muted-foreground"><Table.Row>{heads.map((head) => <Table.Head className={head === "操作" ? "px-4 py-3 text-right" : "px-4 py-3"} key={head}>{head}</Table.Head>)}</Table.Row></Table.Header><Table.Body className="divide-y bg-card">{children}</Table.Body></Table></CardContent></Card>;
}
function EmptyRow({ colSpan, text }: { colSpan: number; text: string }): React.ReactElement {
return <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={colSpan}>{text}</Table.Cell></Table.Row>;
}
function RowActions({ disabled, onDelete, onEdit }: { disabled: boolean; onDelete: () => void; onEdit: () => void }): React.ReactElement {
return <div className="flex justify-end gap-2"><Button disabled={disabled} onClick={onEdit} size="sm" type="button" variant="outline"></Button><Button disabled={disabled} onClick={onDelete} size="sm" type="button" variant="outline"></Button></div>;
}