feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -1,5 +1,6 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
CalendarClock,
LoaderCircle,
@@ -25,12 +26,21 @@ import type {
PeripheralSampleClient,
PeripheralWorkOrder
} from "./client";
import {
EventLocationPicker,
type EventGpsCoordinate
} from "./event-location-picker";
import { mergeEventLocation } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
refreshGeneration?: number;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
@@ -89,6 +99,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
busy={busy}
mutate={mutate}
client={client}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveAddress}
onAgentTrigger={onAgentTrigger}
/>
)}
@@ -96,12 +109,15 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
);
}
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
function WorkOrders({ orders, canWrite, busy, mutate, client, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
@@ -116,9 +132,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [feedbackAt, setFeedbackAt] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
const [eventCoordinate, setEventCoordinate] = useState<EventGpsCoordinate | null>(null);
const addressLookup = useEventLocationAddress({ active: eventEditorOpen, coordinate: eventCoordinate, resolveAddress, onChange: setEventLocation });
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
@@ -140,6 +159,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setFeedbackRequirements("");
setProgressNote("");
setFeedbackAt("");
};
const closeEditor = () => {
@@ -182,6 +202,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
setProgressNote("");
setFeedbackAt(completedFeedbackTime(item));
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
@@ -199,26 +220,29 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
const waterQuality = eventKind === "water_quality_complaint";
const submittedLocation = mergeEventLocation(eventLocation, eventCoordinate);
const workOrder = await client.createWorkOrder({
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
title: waterQuality ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
summary: eventDescription.trim(),
status: "open",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
location: submittedLocation,
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
setEventCoordinate(null);
setEventDescription("");
await triggerAgent(workOrder);
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const feedbackTimeChanged = feedbackAt !== (editing ? completedFeedbackTime(editing) : "");
const canSave = canWrite && !busy && title.trim() && summary.trim() && (!feedbackTimeChanged || progressNote.trim());
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
@@ -251,6 +275,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <div className="mt-3"><DateTimeField contentClassName="workbench-shadcn-surface" label="现场反馈时间(可选)" value={feedbackAt} onChange={setFeedbackAt} disabled={!canWrite || Boolean(busy)} /><p className="mt-1 text-[11px] text-muted-foreground">选择实际反馈时间并填写现场反馈。工单完工后,Agent 从该时间起复核 SCADA;留空使用当前时间。</p></div> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -271,7 +296,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
progress_note: progressNote.trim() || null,
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
});
} else {
await client.createWorkOrder({
@@ -334,16 +360,16 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div>
{eventEditorOpen ? (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4 sm:p-5">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="mt-4 grid gap-4 sm:grid-cols-2 [&>div>label]:mb-2 [&>div>label]:leading-4">
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
<EventLocationPicker location={eventLocation} addressLookup={addressLookup} coordinate={eventCoordinate} onChange={setEventCoordinate} disabled={!canWrite || Boolean(busy)} mapReady={mapReady} mapRef={mapRef} />
<label className="mt-5 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-2 min-h-20" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-5 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || addressLookup.pending || (!eventLocation.trim() && !eventCoordinate) || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
</Button>
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
@@ -574,3 +600,9 @@ function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
function completedFeedbackTime(item: PeripheralWorkOrder): string {
if (item.status !== "completed") return "";
const feedback = (item.activity ?? []).filter((entry) => entry.kind === "feedback").at(-1);
return feedback ? toLocalInput(new Date(feedback.occurred_at)) : "";
}