feat: add emergency incident workspace
This commit is contained in:
80
.trellis/tasks/07-02-emergency-incident-workspace/design.md
Normal file
80
.trellis/tasks/07-02-emergency-incident-workspace/design.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "emergency-incident-workspace",
|
||||
"title": "安全应急事件工作台",
|
||||
"description": "",
|
||||
"status": "planning",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import EmergencyPage from "../../emergency/page";
|
||||
|
||||
export default function ScopedEmergencyPage(): React.ReactElement {
|
||||
export default async function ScopedEmergencyPage(): Promise<React.ReactElement> {
|
||||
return EmergencyPage();
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ModulePage
|
||||
title="安全应急"
|
||||
eyebrow="紧急呼叫与事件处理"
|
||||
description="待接入紧急呼叫、接警派发、事件处理、完成归档和通知数据源。"
|
||||
icon={ShieldAlert}
|
||||
phase="待接入"
|
||||
/>
|
||||
);
|
||||
export default async function EmergencyPage(): Promise<React.ReactElement> {
|
||||
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 <EmergencyWorkspaceClient canManage={hasPermission(context.permissions, "incident:manage")} initialData={data} />;
|
||||
}
|
||||
|
||||
248
app/api/emergency/emergency-routes.test.ts
Normal file
248
app/api/emergency/emergency-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
56
app/api/emergency/incidents/[id]/route.ts
Normal file
56
app/api/emergency/incidents/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
62
app/api/emergency/incidents/route.ts
Normal file
62
app/api/emergency/incidents/route.ts
Normal 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);
|
||||
}
|
||||
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