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,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.

View File

@@ -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.

View File

@@ -3,7 +3,7 @@
"name": "emergency-incident-workspace", "name": "emergency-incident-workspace",
"title": "安全应急事件工作台", "title": "安全应急事件工作台",
"description": "", "description": "",
"status": "planning", "status": "in_progress",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,

View File

@@ -1,5 +1,5 @@
import EmergencyPage from "../../emergency/page"; import EmergencyPage from "../../emergency/page";
export default function ScopedEmergencyPage(): React.ReactElement { export default async function ScopedEmergencyPage(): Promise<React.ReactElement> {
return EmergencyPage(); return EmergencyPage();
} }

View File

@@ -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 { export default async function EmergencyPage(): Promise<React.ReactElement> {
return ( const context = await getCurrentAuthContext();
<ModulePage if (!context) {
title="安全应急" redirect("/login");
eyebrow="紧急呼叫与事件处理" }
description="待接入紧急呼叫、接警派发、事件处理、完成归档和通知数据源。"
icon={ShieldAlert} if (!hasPermission(context.permissions, "incident:read")) {
phase="待接入" 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 <EmergencyWorkspaceClient canManage={hasPermission(context.permissions, "incident:manage")} initialData={data} />;
} }

View File

@@ -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> = {}): 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<string, unknown>): 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<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
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();
});
});

View File

@@ -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<Response> {
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 });
}

View File

@@ -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<Response> {
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<Response> {
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);
}

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

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