feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -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)) : "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user