From a0e50a9e833a7d5d3aed84dcfefb466358957283 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Fri, 3 Jul 2026 00:41:09 -0700 Subject: [PATCH] feat: add emergency incident workspace --- .../design.md | 80 +++++ .../implement.md | 38 +++ .../task.json | 2 +- .../app/[organizationSlug]/emergency/page.tsx | 2 +- app/(app)/app/emergency/page.tsx | 35 +- app/api/emergency/emergency-routes.test.ts | 248 ++++++++++++++ app/api/emergency/incidents/[id]/route.ts | 56 ++++ app/api/emergency/incidents/route.ts | 62 ++++ .../components/EmergencyWorkspaceClient.tsx | 302 ++++++++++++++++++ modules/emergency/server/operations.ts | 152 +++++++++ modules/emergency/types.ts | 123 +++++++ 11 files changed, 1086 insertions(+), 14 deletions(-) create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/design.md create mode 100644 .trellis/tasks/07-02-emergency-incident-workspace/implement.md create mode 100644 app/api/emergency/emergency-routes.test.ts create mode 100644 app/api/emergency/incidents/[id]/route.ts create mode 100644 app/api/emergency/incidents/route.ts create mode 100644 modules/emergency/components/EmergencyWorkspaceClient.tsx create mode 100644 modules/emergency/server/operations.ts create mode 100644 modules/emergency/types.ts diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/design.md b/.trellis/tasks/07-02-emergency-incident-workspace/design.md new file mode 100644 index 0000000..ede51f3 --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/design.md @@ -0,0 +1,80 @@ +# Emergency Incident Workspace Design + +## Summary + +Replace `/app/emergency` with a real safety/emergency incident workspace using the existing `systemIncidents` Drizzle table. The MVP covers manual event creation, status triage, filters, and metrics. + +## Route Boundary + +- Unscoped route: `app/(app)/app/emergency/page.tsx` +- Scoped route: `app/(app)/app/[organizationSlug]/emergency/page.tsx` +- Navigation remains under `运营`. +- Permissions: + - read: `incident:read` + - mutate: `incident:manage` + +Server Component behavior: + +1. Load `getCurrentAuthContext()`. +2. Redirect unauthenticated users to `/login`. +3. Redirect users without `incident:read` to dashboard. +4. Require active organization. +5. Load incidents through `listEmergencyIncidentData(organizationId)`. +6. Render client workspace with `canManage`. + +## Data Model + +Use existing `system_incidents`: + +- `organizationId` +- `severity`: `info | warning | critical` +- `status`: `open | acknowledged | resolved | closed` +- `title` +- `description` +- `source` +- acknowledgement / resolution account and time fields + +No schema change is required for MVP. Elder/bed linking stays future work for the context optimization task. + +## Server Helpers + +Create `modules/emergency/`: + +- `modules/emergency/types.ts` + - labels, DTOs, create/status validators. +- `modules/emergency/server/operations.ts` + - `listEmergencyIncidentData(organizationId: string)` + - `createEmergencyIncident(input)` + - `updateEmergencyIncidentStatus(input)` +- `modules/emergency/components/EmergencyWorkspaceClient.tsx` + - metrics, filters, event table, create dialog, status actions. + +## API Design + +- `GET /api/emergency/incidents` + - permission: `incident:read` + - response: `{ success: true; reason: string; data }` +- `POST /api/emergency/incidents` + - permission: `incident:manage` + - request: `{ severity; title; description; source }` + - creates an organization-scoped incident +- `PATCH /api/emergency/incidents/[id]` + - permission: `incident:manage` + - request: `{ status }` + - updates only rows in the active organization + +All create/update mutations write audit logs after successful persistence. + +## UI Design + +- Metrics: open, acknowledged, critical, resolved/closed today. +- Filters: status, severity, search by title/source/description. +- Dense table columns: event, severity, status, source, created time, updated time, actions. +- Actions: acknowledge, resolve, close. +- Manual creation dialog for authorized operators. +- Read-only users can see rows but not mutation controls. + +## Compatibility + +- Existing `/api/system/incidents/[id]` stays for system status settings. +- The emergency workspace uses dedicated `/api/emergency/...` routes so tests and UI contracts are local to the operational module. diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/implement.md b/.trellis/tasks/07-02-emergency-incident-workspace/implement.md new file mode 100644 index 0000000..e7dba01 --- /dev/null +++ b/.trellis/tasks/07-02-emergency-incident-workspace/implement.md @@ -0,0 +1,38 @@ +# Implementation Plan + +## 1. Types And Tests + +- Add emergency DTOs and validators. +- Add `app/api/emergency/emergency-routes.test.ts` first. +- Cover list, create, status update, permission denial, missing organization, invalid input, and missing/cross-organization incident. + +## 2. Server Operations And API Routes + +- Add `modules/emergency/server/operations.ts`. +- Add `GET`/`POST /api/emergency/incidents`. +- Add `PATCH /api/emergency/incidents/[id]`. +- Use `requirePermission`, active organization checks, structured failures, and audit logs. + +## 3. Workspace UI + +- Replace `app/(app)/app/emergency/page.tsx`. +- Keep scoped route delegating to the unscoped route. +- Add `modules/emergency/components/EmergencyWorkspaceClient.tsx`. +- Implement metrics, filters, create dialog, and status action buttons. + +## 4. Default Data + +- Existing default workspace already inserts representative `systemIncidents`. +- Review whether the seed includes open/acknowledged/resolved and critical/warning/info; adjust only if needed. + +## 5. Verification + +- `pnpm test` +- `pnpm lint` +- `pnpm type-check` +- `pnpm build` + +## Rollback Points + +- Remove `modules/emergency`, emergency API routes, and page changes. +- No migration rollback expected unless implementation discovers schema changes are required. diff --git a/.trellis/tasks/07-02-emergency-incident-workspace/task.json b/.trellis/tasks/07-02-emergency-incident-workspace/task.json index 51f1193..57d9ec4 100644 --- a/.trellis/tasks/07-02-emergency-incident-workspace/task.json +++ b/.trellis/tasks/07-02-emergency-incident-workspace/task.json @@ -3,7 +3,7 @@ "name": "emergency-incident-workspace", "title": "安全应急事件工作台", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/app/(app)/app/[organizationSlug]/emergency/page.tsx b/app/(app)/app/[organizationSlug]/emergency/page.tsx index 77057b4..a5df2c7 100644 --- a/app/(app)/app/[organizationSlug]/emergency/page.tsx +++ b/app/(app)/app/[organizationSlug]/emergency/page.tsx @@ -1,5 +1,5 @@ import EmergencyPage from "../../emergency/page"; -export default function ScopedEmergencyPage(): React.ReactElement { +export default async function ScopedEmergencyPage(): Promise { return EmergencyPage(); } diff --git a/app/(app)/app/emergency/page.tsx b/app/(app)/app/emergency/page.tsx index 26bc86a..61961bd 100644 --- a/app/(app)/app/emergency/page.tsx +++ b/app/(app)/app/emergency/page.tsx @@ -1,15 +1,26 @@ -import { ShieldAlert } from "lucide-react"; +import { redirect } from "next/navigation"; -import { ModulePage } from "@/modules/shared/components/ModulePage"; +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { hasPermission } from "@/modules/core/server/permissions"; +import { EmergencyWorkspaceClient } from "@/modules/emergency/components/EmergencyWorkspaceClient"; +import { listEmergencyIncidentData } from "@/modules/emergency/server/operations"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; -export default function EmergencyPage(): React.ReactElement { - return ( - - ); +export default async function EmergencyPage(): Promise { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + if (!hasPermission(context.permissions, "incident:read")) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + + const organizationId = context.organization?.id; + if (!organizationId) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + + const data = await listEmergencyIncidentData(organizationId); + return ; } diff --git a/app/api/emergency/emergency-routes.test.ts b/app/api/emergency/emergency-routes.test.ts new file mode 100644 index 0000000..786f696 --- /dev/null +++ b/app/api/emergency/emergency-routes.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import type { AuthContext } from "@/modules/core/types"; +import type { EmergencyIncident, EmergencyIncidentData } from "@/modules/emergency/types"; +import { + createEmergencyIncident, + listEmergencyIncidentData, + updateEmergencyIncidentStatus, +} from "@/modules/emergency/server/operations"; + +vi.mock("@/modules/core/server/auth", () => ({ + requirePermission: vi.fn(), +})); + +vi.mock("@/modules/core/server/audit", () => ({ + recordAuditLog: vi.fn(), +})); + +vi.mock("@/modules/emergency/server/operations", () => ({ + createEmergencyIncident: vi.fn(), + isEmergencyMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false), + listEmergencyIncidentData: vi.fn(), + updateEmergencyIncidentStatus: vi.fn(), +})); + +const account: AuthContext["account"] = { + id: "account-1", + name: "Admin", + email: "admin@example.com", + avatarUrl: "", + role: "org_admin", + status: "active", + createdAt: "2026-07-02T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", +}; + +function createAuthContext(overrides: Partial = {}): AuthContext { + return { + account, + organization: { + id: "org-1", + name: "Demo Org", + slug: "demo-org", + status: "active", + registrationEnabled: true, + oidcEnabled: false, + oidcIssuerUrl: "", + oidcClientId: "", + oidcHasClientSecret: false, + oidcScopes: "openid profile email", + oidcRedirectUri: "", + oidcAvatarClaim: "picture", + oidcAutoProvision: false, + createdAt: "2026-07-02T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + }, + organizations: [], + permissions: ["incident:read", "incident:manage"], + session: { + id: "session-1", + accountId: "account-1", + activeOrganizationId: "org-1", + createdAt: "2026-07-02T00:00:00.000Z", + expiresAt: "2026-07-09T00:00:00.000Z", + }, + ...overrides, + }; +} + +const incident: EmergencyIncident = { + id: "incident-1", + organizationId: "org-1", + severity: "critical", + status: "open", + title: "夜间跌倒风险", + description: "重点照护房巡检发现老人离床。", + source: "人工上报", + acknowledgedAt: undefined, + acknowledgedByAccountId: undefined, + acknowledgedByName: undefined, + resolvedAt: undefined, + resolvedByAccountId: undefined, + resolvedByName: undefined, + createdAt: "2026-07-02T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", +}; + +const emergencyData: EmergencyIncidentData = { + incidents: [incident], + metrics: { + open: 1, + acknowledged: 0, + critical: 1, + resolvedToday: 0, + }, +}; + +function allowAuth(): void { + vi.mocked(requirePermission).mockResolvedValue({ + success: true, + context: createAuthContext(), + }); +} + +function jsonRequest(body: Record): Request { + return new Request("http://localhost/api/emergency/incidents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function readJson(response: Response): Promise> { + return (await response.json()) as Record; +} + +beforeEach(() => { + vi.clearAllMocks(); + allowAuth(); +}); + +describe("emergency incident API routes", () => { + it("loads emergency incidents for the active organization", async () => { + vi.mocked(listEmergencyIncidentData).mockResolvedValue(emergencyData); + + const { GET } = await import("./incidents/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual(emergencyData); + expect(listEmergencyIncidentData).toHaveBeenCalledWith("org-1"); + }); + + it("returns permission failures before loading incidents", async () => { + const denied = Response.json({ success: false, reason: "权限不足" }, { status: 403 }); + vi.mocked(requirePermission).mockResolvedValue({ success: false, response: denied }); + + const { GET } = await import("./incidents/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(403); + expect(body.reason).toBe("权限不足"); + expect(listEmergencyIncidentData).not.toHaveBeenCalled(); + }); + + it("requires an active organization before loading incidents", async () => { + vi.mocked(requirePermission).mockResolvedValue({ + success: true, + context: createAuthContext({ organization: undefined }), + }); + + const { GET } = await import("./incidents/route"); + const response = await GET(); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.reason).toBe("请选择机构后查看应急事件"); + expect(listEmergencyIncidentData).not.toHaveBeenCalled(); + }); + + it("creates a manual emergency incident", async () => { + vi.mocked(createEmergencyIncident).mockResolvedValue(incident); + + const { POST } = await import("./incidents/route"); + const response = await POST( + jsonRequest({ + severity: "critical", + title: "夜间跌倒风险", + description: "重点照护房巡检发现老人离床。", + source: "人工上报", + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(201); + expect(body.success).toBe(true); + expect(createEmergencyIncident).toHaveBeenCalledWith(expect.objectContaining({ organizationId: "org-1" })); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "emergency.incident.create" })); + }); + + it("rejects invalid create input", async () => { + const { POST } = await import("./incidents/route"); + const response = await POST(jsonRequest({ severity: "critical", title: "" })); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.reason).toBe("事件标题和描述不能为空"); + expect(createEmergencyIncident).not.toHaveBeenCalled(); + expect(recordAuditLog).not.toHaveBeenCalled(); + }); + + it("updates incident status", async () => { + vi.mocked(updateEmergencyIncidentStatus).mockResolvedValue({ + ...incident, + status: "acknowledged", + acknowledgedAt: "2026-07-02T01:00:00.000Z", + acknowledgedByAccountId: "account-1", + acknowledgedByName: "Admin", + updatedAt: "2026-07-02T01:00:00.000Z", + }); + + const { PATCH } = await import("./incidents/[id]/route"); + const response = await PATCH(jsonRequest({ status: "acknowledged" }), { + params: Promise.resolve({ id: "incident-1" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(updateEmergencyIncidentStatus).toHaveBeenCalledWith(expect.objectContaining({ accountId: "account-1", id: "incident-1", organizationId: "org-1" })); + expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "emergency.incident.update" })); + }); + + it("rejects invalid incident status", async () => { + const { PATCH } = await import("./incidents/[id]/route"); + const response = await PATCH(jsonRequest({ status: "triaged" }), { + params: Promise.resolve({ id: "incident-1" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.reason).toBe("事件状态无效"); + expect(updateEmergencyIncidentStatus).not.toHaveBeenCalled(); + }); + + it("returns structured failures for missing or cross-organization incidents", async () => { + vi.mocked(updateEmergencyIncidentStatus).mockResolvedValue({ + success: false, + reason: "应急事件不存在", + status: 404, + }); + + const { PATCH } = await import("./incidents/[id]/route"); + const response = await PATCH(jsonRequest({ status: "acknowledged" }), { + params: Promise.resolve({ id: "incident-from-other-org" }), + }); + const body = await readJson(response); + + expect(response.status).toBe(404); + expect(body.reason).toBe("应急事件不存在"); + expect(recordAuditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/emergency/incidents/[id]/route.ts b/app/api/emergency/incidents/[id]/route.ts new file mode 100644 index 0000000..b2e7992 --- /dev/null +++ b/app/api/emergency/incidents/[id]/route.ts @@ -0,0 +1,56 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { isEmergencyMutationFailure, updateEmergencyIncidentStatus } from "@/modules/emergency/server/operations"; +import { validateEmergencyIncidentStatusUpdateInput } from "@/modules/emergency/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("incident:manage", { + action: "emergency.incident.update", + targetType: "incident", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后处理应急事件", 400); + } + + const input = validateEmergencyIncidentStatusUpdateInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const incident = await updateEmergencyIncidentStatus({ + ...input.data, + id, + organizationId, + accountId: auth.context.account.id, + }); + if (isEmergencyMutationFailure(incident)) { + return jsonFailure(incident.reason, incident.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "emergency.incident.update", + targetType: "incident", + targetId: incident.id, + result: "success", + reason: `处理应急事件:${incident.title}`, + }); + + return jsonSuccess("应急事件已更新", { incident }); +} diff --git a/app/api/emergency/incidents/route.ts b/app/api/emergency/incidents/route.ts new file mode 100644 index 0000000..bee04db --- /dev/null +++ b/app/api/emergency/incidents/route.ts @@ -0,0 +1,62 @@ +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { createEmergencyIncident, isEmergencyMutationFailure, listEmergencyIncidentData } from "@/modules/emergency/server/operations"; +import { validateEmergencyIncidentCreateInput } from "@/modules/emergency/types"; + +export async function GET(): Promise { + const auth = await requirePermission("incident:read", { + action: "emergency.incident.list", + targetType: "incident", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后查看应急事件", 400); + } + + const data = await listEmergencyIncidentData(organizationId); + return jsonSuccess("应急事件已加载", { data }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("incident:manage", { + action: "emergency.incident.create", + targetType: "incident", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后创建应急事件", 400); + } + + const input = validateEmergencyIncidentCreateInput(await readJsonBody(request)); + if (!input.success) { + return jsonFailure(input.reason); + } + + const incident = await createEmergencyIncident({ ...input.data, organizationId }); + if (isEmergencyMutationFailure(incident)) { + return jsonFailure(incident.reason, incident.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "emergency.incident.create", + targetType: "incident", + targetId: incident.id, + result: "success", + reason: `创建应急事件:${incident.title}`, + }); + + return jsonSuccess("应急事件已创建", { incident }, 201); +} diff --git a/modules/emergency/components/EmergencyWorkspaceClient.tsx b/modules/emergency/components/EmergencyWorkspaceClient.tsx new file mode 100644 index 0000000..ec6426b --- /dev/null +++ b/modules/emergency/components/EmergencyWorkspaceClient.tsx @@ -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 { + 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): Promise { + 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 { + 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 ( +
+
+ + + + +
+ +
+
+

安全应急

+

查看、确认、解决和关闭机构内安全应急事件。

+
+
+ + setSeverityFilter(value as "all" | IncidentSeverity)} + options={[ + { value: "all", label: "全部级别" }, + ...Object.entries(INCIDENT_SEVERITY_LABELS).map(([value, label]) => ({ value, label })), + ]} + value={severityFilter} + /> + +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + + + + 应急事件 + 状态处理会记录确认人、解决人和审计日志。 + + + + + + 事件 + 级别 + 状态 + 来源 + 确认/解决 + 更新时间 + 操作 + + + + {filteredIncidents.map((incident) => ( + + +

{incident.title}

+

{incident.description}

+
+ + {INCIDENT_SEVERITY_LABELS[incident.severity]} + + + {INCIDENT_STATUS_LABELS[incident.status]} + + {incident.source} + + {incident.resolvedByName ?? incident.acknowledgedByName ?? "-"} + + {formatDateTime(incident.updatedAt)} + +
+ {incident.status === "open" ? ( + + ) : null} + {incident.status === "open" || incident.status === "acknowledged" ? ( + + ) : null} + {incident.status === "resolved" ? ( + + ) : null} +
+
+
+ ))} + {filteredIncidents.length === 0 ? ( + + + 暂无应急事件 + + + ) : null} +
+
+
+
+ + setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件"> +
+ setCreateForm((current) => ({ ...current, title: event.target.value }))} required value={createForm.title} /> + setCreateForm((current) => ({ ...current, source: event.target.value }))} value={createForm.source} /> +