feat: add emergency incident workspace
This commit is contained in:
302
modules/emergency/components/EmergencyWorkspaceClient.tsx
Normal file
302
modules/emergency/components/EmergencyWorkspaceClient.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { CheckCircle2, CircleAlert, Search, ShieldAlert, Siren } 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 type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { IncidentSeverity, IncidentStatus } from "@/modules/core/types";
|
||||
import type { EmergencyIncident, EmergencyIncidentData } from "@/modules/emergency/types";
|
||||
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
|
||||
|
||||
type EmergencyWorkspaceClientProps = {
|
||||
canManage: boolean;
|
||||
initialData: EmergencyIncidentData;
|
||||
};
|
||||
|
||||
const emptyCreateForm = {
|
||||
severity: "warning" as IncidentSeverity,
|
||||
title: "",
|
||||
description: "",
|
||||
source: "人工上报",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | undefined): string {
|
||||
return value ? new Date(value).toLocaleString("zh-CN") : "-";
|
||||
}
|
||||
|
||||
function severityVariant(severity: IncidentSeverity): "danger" | "secondary" | "warning" {
|
||||
if (severity === "critical") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
return severity === "warning" ? "warning" : "secondary";
|
||||
}
|
||||
|
||||
function statusVariant(status: IncidentStatus): "success" | "secondary" | "warning" {
|
||||
if (status === "open") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return status === "resolved" || status === "closed" ? "success" : "secondary";
|
||||
}
|
||||
|
||||
export function EmergencyWorkspaceClient({ canManage, initialData }: EmergencyWorkspaceClientProps): React.ReactElement {
|
||||
const [data, setData] = useState(initialData);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | IncidentStatus>("all");
|
||||
const [severityFilter, setSeverityFilter] = useState<"all" | IncidentSeverity>("all");
|
||||
const [message, setMessage] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredIncidents = useMemo(
|
||||
() =>
|
||||
data.incidents.filter((incident) => {
|
||||
const matchesQuery =
|
||||
!normalizedQuery ||
|
||||
[incident.title, incident.description, incident.source].join(" ").toLowerCase().includes(normalizedQuery);
|
||||
const matchesStatus = statusFilter === "all" || incident.status === statusFilter;
|
||||
const matchesSeverity = severityFilter === "all" || incident.severity === severityFilter;
|
||||
return matchesQuery && matchesStatus && matchesSeverity;
|
||||
}),
|
||||
[data.incidents, normalizedQuery, severityFilter, statusFilter],
|
||||
);
|
||||
|
||||
async function refreshData(): Promise<void> {
|
||||
const response = await fetch("/api/emergency/incidents");
|
||||
const result = (await response.json()) as ApiResult<{ data: EmergencyIncidentData }>;
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function createIncident(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权创建应急事件");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch("/api/emergency/incidents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(createForm),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ incident: EmergencyIncident }>;
|
||||
setIsPending(false);
|
||||
setMessage(result.reason);
|
||||
|
||||
if (result.success) {
|
||||
setIsCreateOpen(false);
|
||||
setCreateForm(emptyCreateForm);
|
||||
await refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(incident: EmergencyIncident, status: IncidentStatus): Promise<void> {
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权处理应急事件");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/emergency/incidents/${incident.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ incident: EmergencyIncident }>;
|
||||
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={CircleAlert} label="待处理" value={data.metrics.open} />
|
||||
<MetricCard icon={ShieldAlert} label="已确认" value={data.metrics.acknowledged} />
|
||||
<MetricCard icon={Siren} label="紧急事件" value={data.metrics.critical} />
|
||||
<MetricCard icon={CheckCircle2} label="今日解决" value={data.metrics.resolvedToday} />
|
||||
</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) => setStatusFilter(value as "all" | IncidentStatus)}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.entries(INCIDENT_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={statusFilter}
|
||||
/>
|
||||
<Select
|
||||
aria-label="事件级别"
|
||||
onValueChange={(value) => setSeverityFilter(value as "all" | IncidentSeverity)}
|
||||
options={[
|
||||
{ value: "all", label: "全部级别" },
|
||||
...Object.entries(INCIDENT_SEVERITY_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={severityFilter}
|
||||
/>
|
||||
<Button disabled={!canManage} onClick={() => setIsCreateOpen(true)} type="button">
|
||||
新建事件
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>应急事件</CardTitle>
|
||||
<CardDescription>状态处理会记录确认人、解决人和审计日志。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table className="w-full min-w-[960px] 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">
|
||||
{filteredIncidents.map((incident) => (
|
||||
<Table.Row key={incident.id}>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<p className="font-medium">{incident.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{incident.description}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={severityVariant(incident.severity)}>{INCIDENT_SEVERITY_LABELS[incident.severity]}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={statusVariant(incident.status)}>{INCIDENT_STATUS_LABELS[incident.status]}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{incident.source}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||||
{incident.resolvedByName ?? incident.acknowledgedByName ?? "-"}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(incident.updatedAt)}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
{incident.status === "open" ? (
|
||||
<Button disabled={!canManage || isPending} onClick={() => updateStatus(incident, "acknowledged")} size="sm" type="button" variant="outline">
|
||||
确认
|
||||
</Button>
|
||||
) : null}
|
||||
{incident.status === "open" || incident.status === "acknowledged" ? (
|
||||
<Button disabled={!canManage || isPending} onClick={() => updateStatus(incident, "resolved")} size="sm" type="button">
|
||||
解决
|
||||
</Button>
|
||||
) : null}
|
||||
{incident.status === "resolved" ? (
|
||||
<Button disabled={!canManage || isPending} onClick={() => updateStatus(incident, "closed")} size="sm" type="button" variant="outline">
|
||||
关闭
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{filteredIncidents.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={7}>
|
||||
暂无应急事件
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog className="max-w-xl" description="手工登记安全、应急或巡检异常事件。" onClose={() => setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件">
|
||||
<form className="grid gap-3" onSubmit={createIncident}>
|
||||
<Input label="事件标题" onChange={(event) => setCreateForm((current) => ({ ...current, title: event.target.value }))} required value={createForm.title} />
|
||||
<Select
|
||||
label="事件级别"
|
||||
onValueChange={(value) => setCreateForm((current) => ({ ...current, severity: value as IncidentSeverity }))}
|
||||
options={Object.entries(INCIDENT_SEVERITY_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
value={createForm.severity}
|
||||
/>
|
||||
<Input label="来源" onChange={(event) => setCreateForm((current) => ({ ...current, source: event.target.value }))} value={createForm.source} />
|
||||
<Textarea
|
||||
label="事件描述"
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, description: event.target.value }))}
|
||||
required
|
||||
value={createForm.description}
|
||||
/>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={() => setIsCreateOpen(false)} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending} type="submit">
|
||||
创建事件
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
|
||||
</div>
|
||||
<Icon className="size-5 text-primary" aria-hidden={true} />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
152
modules/emergency/server/operations.ts
Normal file
152
modules/emergency/server/operations.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { accounts, systemIncidents } from "@/modules/core/server/schema";
|
||||
import type {
|
||||
EmergencyIncident,
|
||||
EmergencyIncidentCreateInput,
|
||||
EmergencyIncidentData,
|
||||
EmergencyIncidentStatusUpdateInput,
|
||||
} from "@/modules/emergency/types";
|
||||
|
||||
export type EmergencyMutationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type AccountNameRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function isEmergencyMutationFailure(value: unknown): value is EmergencyMutationFailure {
|
||||
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 buildNameMap(rows: AccountNameRow[]): Map<string, string> {
|
||||
return new Map(rows.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
function toEmergencyIncident(row: typeof systemIncidents.$inferSelect, accountNameById: Map<string, string>): EmergencyIncident {
|
||||
const acknowledgedByAccountId = row.acknowledgedByAccountId ?? undefined;
|
||||
const resolvedByAccountId = row.resolvedByAccountId ?? undefined;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId ?? undefined,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
source: row.source,
|
||||
acknowledgedByAccountId,
|
||||
acknowledgedByName: acknowledgedByAccountId ? accountNameById.get(acknowledgedByAccountId) : undefined,
|
||||
acknowledgedAt: optionalIso(row.acknowledgedAt),
|
||||
resolvedByAccountId,
|
||||
resolvedByName: resolvedByAccountId ? accountNameById.get(resolvedByAccountId) : undefined,
|
||||
resolvedAt: optionalIso(row.resolvedAt),
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEmergencyIncidentData(organizationId: string): Promise<EmergencyIncidentData> {
|
||||
const database = getDatabase();
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const incidentRows = await database
|
||||
.select()
|
||||
.from(systemIncidents)
|
||||
.where(eq(systemIncidents.organizationId, organizationId))
|
||||
.orderBy(desc(systemIncidents.createdAt));
|
||||
|
||||
const accountIds = Array.from(
|
||||
new Set(
|
||||
incidentRows
|
||||
.flatMap((incident) => [incident.acknowledgedByAccountId, incident.resolvedByAccountId])
|
||||
.filter((accountId): accountId is string => typeof accountId === "string"),
|
||||
),
|
||||
);
|
||||
const accountRows =
|
||||
accountIds.length > 0 ? await database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [];
|
||||
const accountNameById = buildNameMap(accountRows);
|
||||
|
||||
return {
|
||||
incidents: incidentRows.map((incident) => toEmergencyIncident(incident, accountNameById)),
|
||||
metrics: {
|
||||
open: incidentRows.filter((incident) => incident.status === "open").length,
|
||||
acknowledged: incidentRows.filter((incident) => incident.status === "acknowledged").length,
|
||||
critical: incidentRows.filter((incident) => incident.severity === "critical").length,
|
||||
resolvedToday: incidentRows.filter(
|
||||
(incident) =>
|
||||
(incident.status === "resolved" || incident.status === "closed") &&
|
||||
incident.resolvedAt !== null &&
|
||||
incident.resolvedAt >= todayStart,
|
||||
).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createEmergencyIncident(
|
||||
input: EmergencyIncidentCreateInput & { organizationId: string },
|
||||
): Promise<EmergencyIncident | EmergencyMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(systemIncidents)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
severity: input.severity,
|
||||
status: "open",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
})
|
||||
.returning();
|
||||
const incident = rows[0];
|
||||
if (!incident) {
|
||||
return { success: false, reason: "应急事件创建失败", status: 500 };
|
||||
}
|
||||
|
||||
return toEmergencyIncident(incident, new Map());
|
||||
}
|
||||
|
||||
export async function updateEmergencyIncidentStatus(
|
||||
input: EmergencyIncidentStatusUpdateInput & { accountId: string; id: string; organizationId: string },
|
||||
): Promise<EmergencyIncident | EmergencyMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const now = new Date();
|
||||
const rows = await database
|
||||
.update(systemIncidents)
|
||||
.set({
|
||||
status: input.status,
|
||||
updatedAt: now,
|
||||
acknowledgedByAccountId: input.status === "acknowledged" ? input.accountId : undefined,
|
||||
acknowledgedAt: input.status === "acknowledged" ? now : undefined,
|
||||
resolvedByAccountId: input.status === "resolved" || input.status === "closed" ? input.accountId : undefined,
|
||||
resolvedAt: input.status === "resolved" || input.status === "closed" ? now : undefined,
|
||||
})
|
||||
.where(and(eq(systemIncidents.id, input.id), eq(systemIncidents.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const incident = rows[0];
|
||||
if (!incident) {
|
||||
return { success: false, reason: "应急事件不存在", status: 404 };
|
||||
}
|
||||
|
||||
const accountRows = await database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId));
|
||||
return toEmergencyIncident(incident, buildNameMap(accountRows));
|
||||
}
|
||||
123
modules/emergency/types.ts
Normal file
123
modules/emergency/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { IncidentSeverity, IncidentStatus } from "@/modules/core/types";
|
||||
|
||||
export const INCIDENT_SEVERITY_VALUES = ["info", "warning", "critical"] as const satisfies readonly IncidentSeverity[];
|
||||
export const INCIDENT_STATUS_VALUES = ["open", "acknowledged", "resolved", "closed"] as const satisfies readonly IncidentStatus[];
|
||||
|
||||
export const INCIDENT_SEVERITY_LABELS: Record<IncidentSeverity, string> = {
|
||||
info: "提示",
|
||||
warning: "预警",
|
||||
critical: "紧急",
|
||||
};
|
||||
|
||||
export const INCIDENT_STATUS_LABELS: Record<IncidentStatus, string> = {
|
||||
open: "待处理",
|
||||
acknowledged: "已确认",
|
||||
resolved: "已解决",
|
||||
closed: "已关闭",
|
||||
};
|
||||
|
||||
export type EmergencyIncident = {
|
||||
id: string;
|
||||
organizationId?: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
title: string;
|
||||
description: string;
|
||||
source: string;
|
||||
acknowledgedByAccountId?: string;
|
||||
acknowledgedByName?: string;
|
||||
acknowledgedAt?: string;
|
||||
resolvedByAccountId?: string;
|
||||
resolvedByName?: string;
|
||||
resolvedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type EmergencyIncidentMetrics = {
|
||||
open: number;
|
||||
acknowledged: number;
|
||||
critical: number;
|
||||
resolvedToday: number;
|
||||
};
|
||||
|
||||
export type EmergencyIncidentData = {
|
||||
incidents: EmergencyIncident[];
|
||||
metrics: EmergencyIncidentMetrics;
|
||||
};
|
||||
|
||||
export type EmergencyIncidentCreateInput = {
|
||||
severity: IncidentSeverity;
|
||||
title: string;
|
||||
description: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type EmergencyIncidentStatusUpdateInput = {
|
||||
status: IncidentStatus;
|
||||
};
|
||||
|
||||
type ValidationSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
|
||||
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 readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
|
||||
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
|
||||
}
|
||||
|
||||
export function validateEmergencyIncidentCreateInput(value: unknown): ValidationResult<EmergencyIncidentCreateInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const severity = readEnum(value.severity, INCIDENT_SEVERITY_VALUES);
|
||||
if (!severity) {
|
||||
return { success: false, reason: "事件级别无效" };
|
||||
}
|
||||
|
||||
const title = readString(value, "title");
|
||||
const description = readString(value, "description");
|
||||
if (!title || !description) {
|
||||
return { success: false, reason: "事件标题和描述不能为空" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
severity,
|
||||
title,
|
||||
description,
|
||||
source: readString(value, "source") || "人工上报",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateEmergencyIncidentStatusUpdateInput(value: unknown): ValidationResult<EmergencyIncidentStatusUpdateInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, INCIDENT_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "事件状态无效" };
|
||||
}
|
||||
|
||||
return { success: true, data: { status } };
|
||||
}
|
||||
Reference in New Issue
Block a user