303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
"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>
|
|
);
|
|
}
|