feat: add emergency incident workspace

This commit is contained in:
2026-07-03 00:41:09 -07:00
parent 63b90394ad
commit a0e50a9e83
11 changed files with 1086 additions and 14 deletions

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