feat: build collaboration workspaces
This commit is contained in:
183
modules/alerts/components/AlertsWorkspaceClient.tsx
Normal file
183
modules/alerts/components/AlertsWorkspaceClient.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"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>;
|
||||
}
|
||||
242
modules/alerts/server/operations.ts
Normal file
242
modules/alerts/server/operations.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { accounts, alertRules, alertTriggers, elders } from "@/modules/core/server/schema";
|
||||
import type { AlertCenterData, AlertRule, AlertRuleInput, AlertTrigger, AlertTriggerInput } from "@/modules/alerts/types";
|
||||
|
||||
export type AlertMutationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export function isAlertMutationFailure(value: unknown): value is AlertMutationFailure {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"success" in value &&
|
||||
(value as { success?: unknown }).success === false
|
||||
);
|
||||
}
|
||||
|
||||
function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function optionalIso(value: Date | null): string | undefined {
|
||||
return value ? iso(value) : undefined;
|
||||
}
|
||||
|
||||
function toAlertRule(row: typeof alertRules.$inferSelect): AlertRule {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
name: row.name,
|
||||
ruleType: row.ruleType,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
conditionSummary: row.conditionSummary,
|
||||
suggestion: row.suggestion,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toAlertTrigger(
|
||||
row: typeof alertTriggers.$inferSelect,
|
||||
ruleNameById: Map<string, string>,
|
||||
elderNameById: Map<string, string>,
|
||||
accountNameById: Map<string, string>,
|
||||
): AlertTrigger {
|
||||
const ruleId = row.ruleId ?? undefined;
|
||||
const elderId = row.elderId ?? undefined;
|
||||
const handledByAccountId = row.handledByAccountId ?? undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
ruleId,
|
||||
ruleName: ruleId ? ruleNameById.get(ruleId) ?? "" : "",
|
||||
elderId,
|
||||
elderName: elderId ? elderNameById.get(elderId) ?? "" : "",
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
handledByAccountId,
|
||||
handledByName: handledByAccountId ? accountNameById.get(handledByAccountId) : undefined,
|
||||
handledAt: optionalIso(row.handledAt),
|
||||
handlingNotes: row.handlingNotes,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function validateRuleAndElder(input: {
|
||||
elderId?: string;
|
||||
organizationId: string;
|
||||
ruleId?: string;
|
||||
}): Promise<{
|
||||
elderNameById: Map<string, string>;
|
||||
ruleNameById: Map<string, string>;
|
||||
} | AlertMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const [ruleRows, elderRows] = await Promise.all([
|
||||
input.ruleId
|
||||
? database.select({ id: alertRules.id, name: alertRules.name }).from(alertRules).where(and(eq(alertRules.id, input.ruleId), eq(alertRules.organizationId, input.organizationId)))
|
||||
: [],
|
||||
input.elderId
|
||||
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.id, input.elderId), eq(elders.organizationId, input.organizationId)))
|
||||
: [],
|
||||
]);
|
||||
if (input.ruleId && !ruleRows[0]) {
|
||||
return { success: false, reason: "预警规则不存在", status: 404 };
|
||||
}
|
||||
if (input.elderId && !elderRows[0]) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
return {
|
||||
ruleNameById: new Map(ruleRows.map((rule) => [rule.id, rule.name])),
|
||||
elderNameById: new Map(elderRows.map((elder) => [elder.id, elder.name])),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAlertCenterData(organizationId: string): Promise<AlertCenterData> {
|
||||
const database = getDatabase();
|
||||
const [ruleRows, triggerRows] = await Promise.all([
|
||||
database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)).orderBy(desc(alertRules.updatedAt)),
|
||||
database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)).orderBy(desc(alertTriggers.updatedAt)),
|
||||
]);
|
||||
const ruleIds = Array.from(new Set(triggerRows.map((trigger) => trigger.ruleId).filter((id): id is string => typeof id === "string")));
|
||||
const elderIds = Array.from(new Set(triggerRows.map((trigger) => trigger.elderId).filter((id): id is string => typeof id === "string")));
|
||||
const accountIds = Array.from(new Set(triggerRows.map((trigger) => trigger.handledByAccountId).filter((id): id is string => typeof id === "string")));
|
||||
const [triggerRuleRows, elderRows, accountRows] = await Promise.all([
|
||||
ruleIds.length > 0 ? database.select({ id: alertRules.id, name: alertRules.name }).from(alertRules).where(inArray(alertRules.id, ruleIds)) : [],
|
||||
elderIds.length > 0 ? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.organizationId, organizationId), inArray(elders.id, elderIds))) : [],
|
||||
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
|
||||
]);
|
||||
const ruleNameById = new Map([...ruleRows.map((rule) => [rule.id, rule.name] as const), ...triggerRuleRows.map((rule) => [rule.id, rule.name] as const)]);
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
enabledRules: ruleRows.filter((rule) => rule.status === "enabled").length,
|
||||
openTriggers: triggerRows.filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged").length,
|
||||
criticalTriggers: triggerRows.filter((trigger) => {
|
||||
const ruleId = trigger.ruleId;
|
||||
const rule = ruleId ? ruleRows.find((item) => item.id === ruleId) : undefined;
|
||||
return rule?.severity === "critical";
|
||||
}).length,
|
||||
resolvedTriggers: triggerRows.filter((trigger) => trigger.status === "resolved" || trigger.status === "closed").length,
|
||||
},
|
||||
rules: ruleRows.map(toAlertRule),
|
||||
triggers: triggerRows.map((trigger) =>
|
||||
toAlertTrigger(
|
||||
trigger,
|
||||
ruleNameById,
|
||||
new Map(elderRows.map((elder) => [elder.id, elder.name])),
|
||||
new Map(accountRows.map((account) => [account.id, account.name])),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAlertRule(input: AlertRuleInput & { organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database.insert(alertRules).values({ ...input }).returning();
|
||||
const rule = rows[0];
|
||||
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则创建失败", status: 500 };
|
||||
}
|
||||
|
||||
export async function updateAlertRule(input: AlertRuleInput & { id: string; organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.update(alertRules)
|
||||
.set({
|
||||
name: input.name,
|
||||
ruleType: input.ruleType,
|
||||
severity: input.severity,
|
||||
status: input.status,
|
||||
conditionSummary: input.conditionSummary,
|
||||
suggestion: input.suggestion,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(alertRules.id, input.id), eq(alertRules.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const rule = rows[0];
|
||||
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function deleteAlertRule(input: { id: string; organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database.delete(alertRules).where(and(eq(alertRules.id, input.id), eq(alertRules.organizationId, input.organizationId))).returning();
|
||||
const rule = rows[0];
|
||||
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function createAlertTrigger(input: AlertTriggerInput & { accountId: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
|
||||
const maps = await validateRuleAndElder(input);
|
||||
if (isAlertMutationFailure(maps)) {
|
||||
return maps;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const now = new Date();
|
||||
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "acknowledged" ? now : undefined;
|
||||
const rows = await database
|
||||
.insert(alertTriggers)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
ruleId: input.ruleId,
|
||||
elderId: input.elderId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
handledByAccountId: handledAt ? input.accountId : undefined,
|
||||
handledAt,
|
||||
handlingNotes: input.handlingNotes,
|
||||
})
|
||||
.returning();
|
||||
const trigger = rows[0];
|
||||
return trigger
|
||||
? toAlertTrigger(trigger, maps.ruleNameById, maps.elderNameById, new Map())
|
||||
: { success: false, reason: "预警触发记录创建失败", status: 500 };
|
||||
}
|
||||
|
||||
export async function updateAlertTrigger(input: AlertTriggerInput & { accountId: string; id: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
|
||||
const maps = await validateRuleAndElder(input);
|
||||
if (isAlertMutationFailure(maps)) {
|
||||
return maps;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const now = new Date();
|
||||
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "acknowledged" ? now : null;
|
||||
const rows = await database
|
||||
.update(alertTriggers)
|
||||
.set({
|
||||
ruleId: input.ruleId ?? null,
|
||||
elderId: input.elderId ?? null,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
handledByAccountId: handledAt ? input.accountId : null,
|
||||
handledAt,
|
||||
handlingNotes: input.handlingNotes,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(alertTriggers.id, input.id), eq(alertTriggers.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const trigger = rows[0];
|
||||
return trigger
|
||||
? toAlertTrigger(trigger, maps.ruleNameById, maps.elderNameById, new Map([[input.accountId, ""]]))
|
||||
: { success: false, reason: "预警触发记录不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function deleteAlertTrigger(input: { id: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database.delete(alertTriggers).where(and(eq(alertTriggers.id, input.id), eq(alertTriggers.organizationId, input.organizationId))).returning();
|
||||
const trigger = rows[0];
|
||||
return trigger ? toAlertTrigger(trigger, new Map(), new Map(), new Map()) : { success: false, reason: "预警触发记录不存在", status: 404 };
|
||||
}
|
||||
184
modules/alerts/types.ts
Normal file
184
modules/alerts/types.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { IncidentSeverity } from "@/modules/core/types";
|
||||
|
||||
export const ALERT_RULE_TYPE_VALUES = ["health", "care", "device", "incident", "admission", "other"] as const;
|
||||
export type AlertRuleType = (typeof ALERT_RULE_TYPE_VALUES)[number];
|
||||
|
||||
export const ALERT_RULE_STATUS_VALUES = ["enabled", "disabled"] as const;
|
||||
export type AlertRuleStatus = (typeof ALERT_RULE_STATUS_VALUES)[number];
|
||||
|
||||
export const ALERT_TRIGGER_STATUS_VALUES = ["open", "acknowledged", "resolved", "closed"] as const;
|
||||
export type AlertTriggerStatus = (typeof ALERT_TRIGGER_STATUS_VALUES)[number];
|
||||
|
||||
export const ALERT_RULE_TYPE_LABELS: Record<AlertRuleType, string> = {
|
||||
health: "健康",
|
||||
care: "护理",
|
||||
device: "设备",
|
||||
incident: "应急",
|
||||
admission: "入住",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const ALERT_RULE_STATUS_LABELS: Record<AlertRuleStatus, string> = {
|
||||
enabled: "启用",
|
||||
disabled: "停用",
|
||||
};
|
||||
|
||||
export const ALERT_TRIGGER_STATUS_LABELS: Record<AlertTriggerStatus, string> = {
|
||||
open: "待处理",
|
||||
acknowledged: "已确认",
|
||||
resolved: "已解决",
|
||||
closed: "已关闭",
|
||||
};
|
||||
|
||||
export type AlertRule = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
ruleType: AlertRuleType;
|
||||
severity: IncidentSeverity;
|
||||
status: AlertRuleStatus;
|
||||
conditionSummary: string;
|
||||
suggestion: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AlertTrigger = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
ruleId?: string;
|
||||
ruleName: string;
|
||||
elderId?: string;
|
||||
elderName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: AlertTriggerStatus;
|
||||
source: string;
|
||||
handledByAccountId?: string;
|
||||
handledByName?: string;
|
||||
handledAt?: string;
|
||||
handlingNotes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AlertCenterData = {
|
||||
metrics: {
|
||||
enabledRules: number;
|
||||
openTriggers: number;
|
||||
criticalTriggers: number;
|
||||
resolvedTriggers: number;
|
||||
};
|
||||
rules: AlertRule[];
|
||||
triggers: AlertTrigger[];
|
||||
};
|
||||
|
||||
export type AlertRuleInput = {
|
||||
name: string;
|
||||
ruleType: AlertRuleType;
|
||||
severity: IncidentSeverity;
|
||||
status: AlertRuleStatus;
|
||||
conditionSummary: string;
|
||||
suggestion: string;
|
||||
};
|
||||
|
||||
export type AlertTriggerInput = {
|
||||
ruleId?: string;
|
||||
elderId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: AlertTriggerStatus;
|
||||
source: string;
|
||||
handlingNotes: string;
|
||||
};
|
||||
|
||||
type ValidationSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
|
||||
const INCIDENT_SEVERITY_VALUES = ["info", "warning", "critical"] as const satisfies readonly IncidentSeverity[];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = readString(source, key);
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
|
||||
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
|
||||
}
|
||||
|
||||
export function validateAlertRuleInput(value: unknown): ValidationResult<AlertRuleInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const name = readString(value, "name");
|
||||
if (!name) {
|
||||
return { success: false, reason: "规则名称不能为空" };
|
||||
}
|
||||
|
||||
const ruleType = readEnum(value.ruleType, ALERT_RULE_TYPE_VALUES);
|
||||
const severity = readEnum(value.severity, INCIDENT_SEVERITY_VALUES);
|
||||
const status = readEnum(value.status, ALERT_RULE_STATUS_VALUES);
|
||||
if (!ruleType || !severity || !status) {
|
||||
return { success: false, reason: "规则类型、级别或状态无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
ruleType,
|
||||
severity,
|
||||
status,
|
||||
conditionSummary: readString(value, "conditionSummary"),
|
||||
suggestion: readString(value, "suggestion"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAlertTriggerInput(value: unknown): ValidationResult<AlertTriggerInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const title = readString(value, "title");
|
||||
if (!title) {
|
||||
return { success: false, reason: "触发记录标题不能为空" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, ALERT_TRIGGER_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "触发记录状态无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
ruleId: readOptionalString(value, "ruleId"),
|
||||
elderId: readOptionalString(value, "elderId"),
|
||||
title,
|
||||
description: readString(value, "description"),
|
||||
status,
|
||||
source: readString(value, "source"),
|
||||
handlingNotes: readString(value, "handlingNotes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user