feat: build collaboration workspaces

This commit is contained in:
2026-07-03 03:02:36 -07:00
parent bf3dd256ab
commit 4ba2a11b88
53 changed files with 9557 additions and 29 deletions

View 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>;
}

View 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
View 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"),
},
};
}