Files
next-tjwater-frontend/src/features/workbench/peripheral-sample/managed-test-resources.tsx
T

609 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
CalendarClock,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Sparkles,
Trash2,
X
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
PeripheralContext,
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, 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[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (capabilities.work_orders !== "available") return;
setBusy("refresh");
setError(null);
try {
setOrders((await client.listWorkOrders()).items);
} catch (nextError) {
setError(message(nextError));
} finally {
setBusy(null);
}
}, [capabilities.work_orders, client]);
useEffect(() => {
void refresh();
}, [refresh, refreshGeneration]);
const mutate = async (key: string, operation: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await operation();
await refresh();
} catch (nextError) {
setError(message(nextError));
setBusy(null);
}
};
return (
<section aria-label="工单管理" className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">工单管理</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">预约安排、投诉调查与爆管处置,按类型查看和维护。</p>
</div>
<Button size="icon-sm" variant="ghost" aria-label="刷新工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
</Button>
</div>
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{capabilities.work_orders !== "available" ? (
<WorkbenchNotice tone="warning" title="工单服务未就绪">
当前项目尚未配置工单存储,请联系管理员。
</WorkbenchNotice>
) : (
<WorkOrders
orders={orders}
canWrite={canWrite}
busy={busy}
mutate={mutate}
client={client}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveAddress}
onAgentTrigger={onAgentTrigger}
/>
)}
</section>
);
}
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);
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
const [assignee, setAssignee] = useState("");
const [location, setLocation] = useState("");
const [scenarioCode, setScenarioCode] = useState("");
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);
const [deleteCandidateId, setDeleteCandidateId] = useState<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const editorTriggerRef = useRef<HTMLButtonElement>(null);
const editingWorkOrderId = editing?.work_order_id;
const clear = () => {
setEditing(null);
setEditorOpen(false);
setTitle("");
setSummary("");
setStatus("draft");
setPriority("normal");
setAssignee("");
setLocation("");
setScenarioCode("");
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setFeedbackRequirements("");
setProgressNote("");
setFeedbackAt("");
};
const closeEditor = () => {
const trigger = editorTriggerRef.current;
const editTriggerId = editingWorkOrderId ? `work-order-edit-${editingWorkOrderId}` : null;
clear();
requestAnimationFrame(() => {
if (editTriggerId) document.getElementById(editTriggerId)?.focus();
else trigger?.focus();
});
};
useEffect(() => {
if (!editorOpen || !titleInputRef.current) return;
titleInputRef.current.focus({ preventScroll: true });
const target = editingWorkOrderId
? titleInputRef.current.closest("article")
: titleInputRef.current;
target?.scrollIntoView({ block: editingWorkOrderId ? "start" : "nearest" });
}, [editorOpen, editingWorkOrderId]);
const create = (trigger: HTMLButtonElement) => {
editorTriggerRef.current = trigger;
clear();
setEditorOpen(true);
};
const edit = (item: PeripheralWorkOrder, trigger: HTMLButtonElement) => {
editorTriggerRef.current = trigger;
setDeleteCandidateId(null);
setEditing(item);
setEditorOpen(true);
setTitle(item.title);
setSummary(item.summary);
setStatus(item.status);
setPriority(item.priority);
setAssignee(item.assignee ?? "");
setLocation(item.location ?? "");
setScenarioCode(item.scenario_code ?? "");
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) => {
if (!onAgentTrigger) {
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
return;
}
try {
await onAgentTrigger(workOrder);
setAgentTriggerFailure(null);
} catch (error) {
setAgentTriggerFailure({ message: message(error), workOrder });
}
};
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 ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
summary: eventDescription.trim(),
status: "open",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: submittedLocation,
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
setEventCoordinate(null);
setEventDescription("");
await triggerAgent(workOrder);
});
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";
const orderEditor = editorOpen ? (
<section
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
className="mt-4 border-t border-border pt-4"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy && !event.defaultPrevented
&& event.currentTarget.contains(event.target as Node)) {
event.preventDefault();
event.stopPropagation();
closeEditor();
}
}}
>
<EditorHeading title={editing ? "编辑工单" : "新增预约工单"} onClose={closeEditor} disabled={Boolean(busy)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect contentClassName="workbench-shadcn-surface" label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect contentClassName="workbench-shadcn-surface" label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-muted-foreground">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-muted-foreground">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField contentClassName="workbench-shadcn-surface" label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-muted-foreground">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-muted-foreground">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<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 () => {
const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
if (editing) {
await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision,
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
clear_assignee: !assignee.trim(),
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
clear_scheduled_at: !scheduledAt,
location: location.trim() || null,
clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null,
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
});
} else {
await client.createWorkOrder({
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
work_order_type: "general",
origin: "system_data",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
feedback_requirements: requirements
});
}
closeEditor();
})}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单" : "创建预约工单"}
</Button>
<Button size="sm" variant="ghost" onClick={closeEditor} disabled={Boolean(busy)}>取消</Button>
</div>
</section>
) : null;
const orderGroups = [
{
id: "appointment",
title: "预约工单",
description: "按预约时间记录派发、执行、复令和现场反馈。",
items: orders.filter((item) => item.work_order_type === "general")
},
{
id: "complaint",
title: "投诉工单",
description: "记录水质投诉的发生信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "water_quality_complaint")
},
{
id: "burst",
title: "爆管上报工单",
description: "记录爆管事件信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "burst_report")
}
];
return (
<div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
<Sparkles size={13} />投诉与爆管上报
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={(event) => create(event.currentTarget)}>
<Plus size={13} />新增预约工单
</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
</div>
{eventEditorOpen ? (
<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-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>
<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>
</div>
</div>
) : null}
{agentTriggerFailure ? (
<WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,调查任务待触发">
<p>{agentTriggerFailure.message}</p>
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
<RefreshCw size={13} />重试触发
</Button>
</WorkbenchNotice>
) : null}
{editorOpen && !editing ? orderEditor : null}
<div aria-label="分类工单列表" className="mt-4 divide-y divide-border">
{orderGroups.map((group) => (
<Disclosure
key={group.id}
aria-label={`${group.title}列表`}
initiallyOpen
className="py-3 [&>summary]:rounded-md [&>summary]:py-1"
label={(
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h5 className="text-sm font-semibold text-foreground">{group.title}</h5>
<p className="mt-0.5 text-[11px] font-normal leading-4 text-muted-foreground">{group.description}</p>
</div>
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
</div>
)}
>
{group.items.length ? (
<ol className="space-y-3 pt-3">
{group.items.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</li>
))}
</ol>
) : (
<p className="py-4 text-xs text-muted-foreground">暂无{group.title}</p>
)}
</Disclosure>
))}
</div>
</div>
);
}
function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, confirmingDelete, onRequestDelete, onCancelDelete, onConfirmDelete }: {
item: PeripheralWorkOrder;
index: number;
canWrite: boolean;
busy: string | null;
editing: boolean;
editor: ReactNode;
onEdit: (trigger: HTMLButtonElement) => void;
confirmingDelete: boolean;
onRequestDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const workOrder = item.work_order_type === "general" ? toScheduledWorkOrder(item) : null;
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const cancelDelete = () => {
onCancelDelete();
deleteButtonRef.current?.focus();
};
return (
<article className="rounded-lg border border-border bg-card p-3 sm:p-4">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">#{String(index + 1).padStart(2, "0")}</span>
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
<h6 className="mt-1 text-balance text-xs font-semibold text-foreground">{item.title}</h6>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{item.summary}</p>
</div>
{!editing ? <div className="flex shrink-0 gap-0.5">
<Button id={`work-order-edit-${item.work_order_id}`} size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={(event) => onEdit(event.currentTarget)}><Pencil size={14} /></Button>
<Button ref={deleteButtonRef} size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} aria-expanded={confirmingDelete} disabled={!canWrite || Boolean(busy) || confirmingDelete} onClick={onRequestDelete}><Trash2 size={14} /></Button>
</div> : null}
</div>
{confirmingDelete ? (
<div
role="group"
aria-label={`确认删除工单 ${item.title}`}
className="mt-3 rounded-lg bg-rose-50 p-3 ring-1 ring-inset ring-rose-200"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
event.stopPropagation();
cancelDelete();
}
}}
>
<p className="break-words text-balance text-xs font-semibold text-rose-900">删除“{item.title}”?</p>
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">外围样板将移除这张工单,历史版本保留。</p>
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">对应的工况任务将在后台同步后移出任务列表。</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="destructive" disabled={Boolean(busy)} onClick={onConfirmDelete}>
{busy === `delete-order-${item.work_order_id}` ? <LoaderCircle className="animate-spin" /> : <Trash2 size={13} />}
确认删除
</Button>
<Button type="button" size="sm" variant="outline" autoFocus disabled={Boolean(busy)} onClick={cancelDelete}>取消</Button>
</div>
</div>
) : null}
{editor}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border pt-2 text-[10px] text-muted-foreground">
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatLocalMonthDayTime(item.scheduled_at)}</span> : null}
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
{workOrder ? (
<Disclosure aria-label={`工单详情 ${item.title}`} label="查看工单详情" className="border-t border-border pt-1">
<WorkOrderReportPanel condition={workOrder} />
</Disclosure>
) : <EventWorkOrderDetails item={item} />}
</div>
</article>
);
}
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
return (
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="border-t border-border pt-3">
<p className="text-[10px] font-semibold text-muted-foreground">事件信息</p>
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatLocalMonthDayTime(item.scheduled_at) : "未记录"} />
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
</dl>
{item.activity?.length ? (
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-border pt-3">
{item.activity.map((entry) => (
<li key={entry.sequence} className="rounded-lg bg-muted/50 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
<span className="font-semibold text-foreground">{entry.title}</span>
<span className="ml-2">{entry.detail}</span>
<span className="mt-1 block text-[10px] text-muted-foreground">{formatLocalMonthDayTime(entry.occurred_at)} · {entry.actor}</span>
</li>
))}
</ol>
) : <p className="mt-3 border-t border-border pt-3 text-[11px] text-muted-foreground">暂无事件活动记录</p>}
</section>
);
}
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
return <div className="min-w-0 rounded-lg bg-muted/50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-muted-foreground">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-foreground">{value}</dd></div>;
}
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
return {
id: item.work_order_id,
kind: "work_order",
code: item.scenario_code ?? item.work_order_id,
externalStatus: item.status,
workOrderScheduledAt: item.scheduled_at,
scheduledAt: item.scheduled_at ?? item.updated_at,
title: item.title,
summary: item.summary,
status: item.status === "in_progress" ? "running" : item.status === "awaiting_reply" ? "awaiting_reply" : item.status === "completed" ? "completed" : item.status === "cancelled" ? "cancelled" : "pending",
riskLevel: item.priority === "urgent" || item.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(item.updated_at),
detail: item.summary,
source: "外围工单测试",
dispatcher: "未记录",
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
location: item.location ?? undefined,
assignee: item.assignee ?? undefined,
feedbackRequirements: item.feedback_requirements ?? [],
activity: (item.activity ?? []).map((entry) => ({ sequence: entry.sequence, occurredAt: entry.occurred_at,
actor: entry.actor, kind: entry.kind, title: entry.title, detail: entry.detail }))
};
}
function EditorHeading({ title, onClose, disabled = false }: { title: string; onClose: () => void; disabled?: boolean }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-foreground">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
}
const WORK_ORDER_STATUS_OPTIONS = [
{ value: "draft", label: "草稿" },
{ value: "open", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "awaiting_reply", label: "待复令" },
{ value: "completed", label: "已完成" },
{ value: "cancelled", label: "已取消" }
];
const PRIORITY_OPTIONS = [{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }];
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function message(error: unknown) { return error instanceof Error ? error.message : "外围工单请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
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)) : "";
}