feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Database,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -16,58 +11,51 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
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 { WorkOrderSubtasksPanel } from "../components/scheduled-condition-detail-panel";
|
||||
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import type { ScheduledWorkOrderItem } from "../types";
|
||||
import type {
|
||||
PeripheralContext,
|
||||
PeripheralSampleClient,
|
||||
PeripheralTestDataRecord,
|
||||
PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
type ResourceView = "orders" | "data";
|
||||
type Inventory = { workOrders: number; testData: number };
|
||||
type PeripheralSubtask = NonNullable<PeripheralWorkOrder["subtasks"]>[number];
|
||||
type PeripheralStage = PeripheralSubtask["stages"][number];
|
||||
|
||||
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange, onAgentTrigger }: {
|
||||
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
|
||||
client: PeripheralSampleClient;
|
||||
canWrite: boolean;
|
||||
capabilities: PeripheralContext["managed_resources"];
|
||||
onInventoryChange?: (inventory: Inventory) => void;
|
||||
refreshGeneration?: number;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
}) {
|
||||
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
|
||||
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
|
||||
const [view, setView] = useState<ResourceView>("orders");
|
||||
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);
|
||||
const [orderResult, dataResult] = await Promise.allSettled([
|
||||
capabilities.work_orders === "available"
|
||||
? client.listWorkOrders()
|
||||
: Promise.resolve({ items: [], next_cursor: null }),
|
||||
capabilities.test_data === "available"
|
||||
? client.listTestData()
|
||||
: Promise.resolve({ items: [], next_cursor: null })
|
||||
]);
|
||||
if (orderResult.status === "fulfilled") setOrders(orderResult.value.items);
|
||||
if (dataResult.status === "fulfilled") setRecords(dataResult.value.items);
|
||||
const failed = [orderResult, dataResult].find((result) => result.status === "rejected");
|
||||
if (failed?.status === "rejected") setError(message(failed.reason));
|
||||
setBusy(null);
|
||||
}, [capabilities.test_data, capabilities.work_orders, client]);
|
||||
try {
|
||||
setOrders((await client.listWorkOrders()).items);
|
||||
} catch (nextError) {
|
||||
setError(message(nextError));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [capabilities.work_orders, client]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
useEffect(() => {
|
||||
onInventoryChange?.({ workOrders: orders.length, testData: records.length });
|
||||
}, [onInventoryChange, orders.length, records.length]);
|
||||
void refresh();
|
||||
}, [refresh, refreshGeneration]);
|
||||
|
||||
const mutate = async (key: string, operation: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
@@ -81,61 +69,44 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
|
||||
}
|
||||
};
|
||||
|
||||
return <WorkbenchSection
|
||||
title="外围工单与测试数据"
|
||||
description="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
|
||||
icon={<ClipboardList size={15} />}
|
||||
tone="teal"
|
||||
bodyClassName="space-y-3"
|
||||
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
|
||||
>
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null}
|
||||
{capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
|
||||
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<ClipboardList size={13} />} onClick={() => setView("orders")}>工单列表</ResourceSwitch>
|
||||
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
|
||||
</div>
|
||||
{view === "orders" ? (
|
||||
capabilities.work_orders === "available"
|
||||
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} onAgentTrigger={onAgentTrigger} />
|
||||
: null
|
||||
) : (
|
||||
capabilities.test_data === "available"
|
||||
? <TestData records={records} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
|
||||
: null
|
||||
)}
|
||||
</WorkbenchSection>;
|
||||
return (
|
||||
<WorkbenchSection
|
||||
title="工单接口测试"
|
||||
description="验证创建、编辑、过程状态、反馈回填与后端同步。"
|
||||
icon={<ClipboardList size={15} />}
|
||||
tone="teal"
|
||||
bodyClassName="space-y-3"
|
||||
actions={(
|
||||
<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
|
||||
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{capabilities.work_orders !== "available" ? (
|
||||
<WorkbenchNotice tone="warning" title="测试工单存储待供给">
|
||||
缺少 peripheral_sample.work_orders 或 work_order_revisions。
|
||||
</WorkbenchNotice>
|
||||
) : (
|
||||
<WorkOrders
|
||||
orders={orders}
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
mutate={mutate}
|
||||
client={client}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
)}
|
||||
</WorkbenchSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSwitch({ active, count, icon, onClick, children }: {
|
||||
active: boolean;
|
||||
count: number;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-9 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-[background-color,color,box-shadow] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
|
||||
active ? "bg-white text-slate-900 shadow-xs" : "text-slate-500 [@media(hover:hover)]:hover:text-slate-800"
|
||||
)}
|
||||
>
|
||||
{icon}<span className="truncate">{children}</span><span className="tabular-nums text-slate-400">{count}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
function StoragePending({ resource, tables }: { resource: string; tables: string }) {
|
||||
return <WorkbenchNotice tone="warning" title={`${resource}存储待供给`}>
|
||||
缺少 peripheral_sample.{tables.replace(" / ", " 或 peripheral_sample.")};原有监测回放和演练仍可使用。
|
||||
</WorkbenchNotice>;
|
||||
}
|
||||
|
||||
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: ResourceProps & {
|
||||
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
|
||||
orders: PeripheralWorkOrder[];
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
|
||||
client: PeripheralSampleClient;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -144,10 +115,17 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
const [summary, setSummary] = useState("");
|
||||
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
|
||||
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
|
||||
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{
|
||||
message: string;
|
||||
workOrder: PeripheralWorkOrder;
|
||||
} | null>(null);
|
||||
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 [subtasks, setSubtasks] = useState<PeripheralSubtask[] | null>(null);
|
||||
const [eventEditorOpen, setEventEditorOpen] = useState(false);
|
||||
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
|
||||
const [eventLocation, setEventLocation] = useState("");
|
||||
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
|
||||
const [eventDescription, setEventDescription] = useState("");
|
||||
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
|
||||
|
||||
const clear = () => {
|
||||
setEditing(null);
|
||||
@@ -156,11 +134,19 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
setSummary("");
|
||||
setStatus("draft");
|
||||
setPriority("normal");
|
||||
setAssignee("");
|
||||
setLocation("");
|
||||
setScenarioCode("");
|
||||
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
|
||||
setSubtasks(null);
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
clear();
|
||||
setEditorOpen(true);
|
||||
setSubtasks([createDefaultSubtask()]);
|
||||
};
|
||||
|
||||
const edit = (item: PeripheralWorkOrder) => {
|
||||
setEditing(item);
|
||||
setEditorOpen(true);
|
||||
@@ -168,7 +154,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
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)) : "");
|
||||
setSubtasks(item.subtasks ?? null);
|
||||
};
|
||||
|
||||
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
|
||||
if (!onAgentTrigger) {
|
||||
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
|
||||
@@ -181,81 +173,244 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
||||
setAgentTriggerFailure({ message: message(error), workOrder });
|
||||
}
|
||||
};
|
||||
const createWaterQualityComplaint = () => void mutate("create-water-quality-order", async () => {
|
||||
|
||||
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
|
||||
const waterQuality = eventKind === "water_quality_complaint";
|
||||
const workOrder = await client.createWorkOrder({
|
||||
title: "居民反映自来水有异味",
|
||||
summary: "演示小区多户居民反映晨间用水有异味,请核查上游水质及二次供水环节。",
|
||||
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
|
||||
summary: eventDescription.trim(),
|
||||
status: "open",
|
||||
priority: "high",
|
||||
priority: waterQuality ? "high" : "urgent",
|
||||
assignee: null,
|
||||
scheduled_at: null,
|
||||
work_order_type: "water_quality_complaint",
|
||||
location: "演示小区",
|
||||
scenario_code: "WQ-SECONDARY-SUPPLY-001"
|
||||
scheduled_at: new Date(eventOccurredAt).toISOString(),
|
||||
work_order_type: eventKind,
|
||||
location: eventLocation.trim(),
|
||||
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
|
||||
subtasks: null
|
||||
});
|
||||
setEventEditorOpen(false);
|
||||
setEventLocation("");
|
||||
setEventDescription("");
|
||||
await triggerAgent(workOrder);
|
||||
});
|
||||
|
||||
return <div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">测试工单</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张工单独立展示派发、执行和复令步骤,步骤状态互不混用。</p></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={createWaterQualityComplaint}>
|
||||
{busy === "create-water-quality-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
水质投诉演示
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
|
||||
const processValid = Boolean(subtasks?.length && subtasks.every(isValidSubtask));
|
||||
const canSave = canWrite && !busy && title.trim() && summary.trim() && processValid;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-semibold text-slate-800">测试工单</h4>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">过程字段将随工单写入,并按原结构同步到预约任务。</p>
|
||||
</div>
|
||||
<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={create}>
|
||||
<Plus size={13} />新增预约工单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{eventEditorOpen ? (
|
||||
<div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<FieldSelect 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 label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
|
||||
</div>
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">事件位置<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-slate-600">现场描述<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}>
|
||||
{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 ? (
|
||||
<section aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"} className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title={editing ? "编辑预约工单" : "新增预约工单"} onClose={clear} />
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600 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 label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
|
||||
<FieldSelect 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-slate-600">负责人<Input className="mt-1 h-9" value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">位置<Input className="mt-1 h-9" value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
|
||||
<label className="block text-xs font-medium text-slate-600">场景编号<Input className="mt-1 h-9" value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
</div>
|
||||
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
|
||||
|
||||
<ProcessEditor subtasks={subtasks} disabled={!canWrite || Boolean(busy)} onChange={setSubtasks} />
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
|
||||
const normalizedSubtasks = subtasks!.map(normalizeSubtask);
|
||||
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(),
|
||||
subtasks: normalizedSubtasks,
|
||||
clear_subtasks: false
|
||||
});
|
||||
} 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",
|
||||
location: location.trim() || null,
|
||||
scenario_code: scenarioCode.trim() || null,
|
||||
subtasks: normalizedSubtasks
|
||||
});
|
||||
}
|
||||
clear();
|
||||
})}>
|
||||
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
|
||||
{editing ? "保存工单与过程" : "创建预约工单"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section aria-label="工单列表" className="mt-3">
|
||||
{orders.length ? (
|
||||
<ol className="space-y-2">
|
||||
{orders.map((item, index) => (
|
||||
<li key={item.work_order_id}>
|
||||
<WorkOrderCard
|
||||
item={item}
|
||||
index={index}
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
onEdit={() => edit(item)}
|
||||
onDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
|
||||
await client.deleteWorkOrder(item.work_order_id, item.revision);
|
||||
if (editing?.work_order_id === item.work_order_id) clear();
|
||||
})}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="创建预约工单或事件工单后,可在这里核对数据库返回结构。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
|
||||
<p>{agentTriggerFailure.message}</p>
|
||||
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
|
||||
{busy === "retry-agent-trigger" ? <LoaderCircle className="animate-spin" /> : <RefreshCw size={13} />}重试触发
|
||||
</Button>
|
||||
</WorkbenchNotice> : null}
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title={editing ? "编辑工单" : "新增工单"} onClose={clear} />
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
|
||||
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
|
||||
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
|
||||
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
|
||||
]} /></div>
|
||||
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
|
||||
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
|
||||
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null, work_order_type: "general", location: null, scenario_code: null });
|
||||
clear();
|
||||
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建工单"}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
|
||||
</div> : null}
|
||||
<WorkOrderList
|
||||
orders={orders}
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
onEdit={edit}
|
||||
onDelete={(item) => void mutate(`delete-order-${item.work_order_id}`, async () => {
|
||||
await client.deleteWorkOrder(item.work_order_id, item.revision);
|
||||
if (editing?.work_order_id === item.work_order_id) clear();
|
||||
})}
|
||||
/>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
function WorkOrderList({ orders, canWrite, busy, onEdit, onDelete }: {
|
||||
orders: PeripheralWorkOrder[];
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
onEdit: (item: PeripheralWorkOrder) => void;
|
||||
onDelete: (item: PeripheralWorkOrder) => void;
|
||||
function ProcessEditor({ subtasks, disabled, onChange }: {
|
||||
subtasks: PeripheralSubtask[] | null;
|
||||
disabled: boolean;
|
||||
onChange: (subtasks: PeripheralSubtask[] | null) => void;
|
||||
}) {
|
||||
return <section aria-label="工单列表" className="mt-3">
|
||||
{orders.length ? <ol className="space-y-2">
|
||||
{orders.map((item, index) => <li key={item.work_order_id}>
|
||||
<WorkOrderCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
|
||||
</li>)}
|
||||
</ol> : <WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="新增工单后,可在这里查看接口返回的工单字段和状态。" />}
|
||||
</section>;
|
||||
if (!subtasks) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 p-3">
|
||||
<p className="text-xs text-slate-500">未提供过程,不根据工单总状态推测阶段。</p>
|
||||
<Button className="mt-2" size="sm" variant="outline" disabled={disabled} onClick={() => onChange([createDefaultSubtask()])}>添加过程</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const updateSubtask = (index: number, next: PeripheralSubtask) => {
|
||||
onChange(subtasks.map((item, itemIndex) => itemIndex === index ? next : item));
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="工单过程" className="mt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h5 className="text-xs font-semibold text-slate-700">预约过程</h5>
|
||||
<span className="text-[10px] text-slate-400">{subtasks.length} 个子任务</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{subtasks.map((subtask, index) => (
|
||||
<Disclosure
|
||||
key={subtask.id}
|
||||
initiallyOpen={index === 0}
|
||||
label={`${index + 1}. ${subtask.title || "未命名子任务"}`}
|
||||
className="rounded-xl border border-slate-200 bg-slate-50 px-3"
|
||||
>
|
||||
<div className="grid gap-2 border-t border-slate-200 py-3 sm:grid-cols-2">
|
||||
<label className="block text-xs font-medium text-slate-600">子任务名称<Input className="mt-1 h-9" value={subtask.title} onChange={(event) => updateSubtask(index, { ...subtask, title: event.target.value })} disabled={disabled} /></label>
|
||||
<FieldSelect label="子任务状态" value={subtask.status} onValueChange={(value) => updateSubtask(index, { ...subtask, status: value as PeripheralSubtask["status"] })} disabled={disabled} options={SUBTASK_STATUS_OPTIONS} />
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">子任务说明<Textarea className="mt-1 min-h-14" value={subtask.summary} onChange={(event) => updateSubtask(index, { ...subtask, summary: event.target.value })} disabled={disabled} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">责任班组<Input className="mt-1 h-9" value={subtask.assignee} onChange={(event) => updateSubtask(index, { ...subtask, assignee: event.target.value })} disabled={disabled} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">处置位置<Input className="mt-1 h-9" value={subtask.location} onChange={(event) => updateSubtask(index, { ...subtask, location: event.target.value })} disabled={disabled} /></label>
|
||||
<DateTimeField label="子任务预约时间" value={toLocalInput(new Date(subtask.scheduled_at))} onChange={(value) => updateSubtask(index, { ...subtask, scheduled_at: new Date(value).toISOString() })} disabled={disabled} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block text-xs font-medium text-slate-600">预计分钟<Input className="mt-1 h-9" inputMode="numeric" value={String(subtask.duration_minutes)} onChange={(event) => updateSubtask(index, { ...subtask, duration_minutes: Number(event.target.value.replace(/\D/g, "")) })} disabled={disabled} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">复令时限<Input className="mt-1 h-9" inputMode="numeric" value={String(subtask.reply_window_minutes)} onChange={(event) => updateSubtask(index, { ...subtask, reply_window_minutes: Number(event.target.value.replace(/\D/g, "")) })} disabled={disabled} /></label>
|
||||
</div>
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">回执要求<Textarea className="mt-1 min-h-14" value={subtask.reply_requirements.join("\n")} onChange={(event) => updateSubtask(index, { ...subtask, reply_requirements: event.target.value.split("\n") })} disabled={disabled} /></label>
|
||||
</div>
|
||||
<div className="grid gap-2 pb-3">
|
||||
{subtask.stages.map((stage, stageIndex) => (
|
||||
<StageEditor
|
||||
key={stage.id}
|
||||
stage={stage}
|
||||
disabled={disabled}
|
||||
onChange={(nextStage) => updateSubtask(index, {
|
||||
...subtask,
|
||||
stages: subtask.stages.map((item, itemIndex) => itemIndex === stageIndex ? nextStage : item)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StageEditor({ stage, disabled, onChange }: {
|
||||
stage: PeripheralStage;
|
||||
disabled: boolean;
|
||||
onChange: (stage: PeripheralStage) => void;
|
||||
}) {
|
||||
return (
|
||||
<section aria-label={`阶段 ${stage.title}`} className="rounded-lg bg-white p-2.5 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-medium text-slate-600">阶段名称<Input className="mt-1 h-9" value={stage.title} onChange={(event) => onChange({ ...stage, title: event.target.value })} disabled={disabled} /></label>
|
||||
<FieldSelect label="阶段状态" value={stage.status} onValueChange={(value) => onChange({ ...stage, status: value as PeripheralStage["status"] })} disabled={disabled} options={STAGE_STATUS_OPTIONS} />
|
||||
<label className="block text-xs font-medium text-slate-600">责任人<Input className="mt-1 h-9" value={stage.owner} onChange={(event) => onChange({ ...stage, owner: event.target.value })} disabled={disabled} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600">时间说明<Input className="mt-1 h-9" value={stage.time_label} onChange={(event) => onChange({ ...stage, time_label: event.target.value })} disabled={disabled} /></label>
|
||||
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">阶段说明<Textarea aria-label="阶段说明" className="mt-1 min-h-14" value={stage.description} onChange={(event) => onChange({ ...stage, description: event.target.value })} disabled={disabled} /></label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
@@ -266,174 +421,158 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return <article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
|
||||
<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-slate-400">工单 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>{item.work_order_type === "water_quality_complaint" ? <StatusBadge tone="warning">水质投诉</StatusBadge> : null}</div>
|
||||
<h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p>
|
||||
{item.location ? <p className="mt-1 text-[10px] text-slate-400">位置:{item.location}</p> : null}
|
||||
const process = toScheduledWorkOrder(item);
|
||||
return (
|
||||
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
|
||||
<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-slate-400">工单 {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-xs font-semibold text-slate-900">{item.title}</h6>
|
||||
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
<Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button>
|
||||
<Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
|
||||
</div>
|
||||
<WorkOrderSteps status={item.status} title={item.title} />
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500">
|
||||
<span>{priorityLabel(item.priority)}优先级</span>
|
||||
<span className="max-w-full truncate font-mono" title={item.work_order_id}>工单号 {item.work_order_id}</span>
|
||||
<span>r{item.revision}</span>
|
||||
{item.assignee ? <span>处理人:{item.assignee}</span> : null}
|
||||
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
type WorkOrderStepStatus = "done" | "current" | "pending" | "skipped";
|
||||
|
||||
const WORK_ORDER_STEPS = [
|
||||
{ id: "dispatch", title: "派发", description: "确认处理班组与计划窗口" },
|
||||
{ id: "execution", title: "执行", description: "按工单要求处置并留痕" },
|
||||
{ id: "reply", title: "复令", description: "回填结果并完成调度复核" }
|
||||
] as const;
|
||||
|
||||
function WorkOrderSteps({ status, title }: {
|
||||
status: PeripheralWorkOrder["status"];
|
||||
title: string;
|
||||
}) {
|
||||
const statuses = workOrderStepStatuses(status);
|
||||
const completed = statuses.filter((item) => item === "done").length;
|
||||
|
||||
return <section aria-label={`工单步骤 ${title}`} className="mt-3 border-t border-slate-200/80 pt-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold text-slate-500">工单步骤</p>
|
||||
<span className="text-[10px] tabular-nums text-slate-400">{completed}/{WORK_ORDER_STEPS.length} 完成</span>
|
||||
</div>
|
||||
<ol className="grid gap-1.5">
|
||||
{WORK_ORDER_STEPS.map((step, index) => {
|
||||
const stepStatus = statuses[index];
|
||||
return <li key={step.id} className="grid min-w-0 grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-lg bg-white px-2 py-2 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
|
||||
<WorkOrderStepIcon status={stepStatus} step={index + 1} />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[11px] font-semibold text-slate-800">{step.title}</span>
|
||||
<span className="block truncate text-[10px] text-slate-400">{step.description}</span>
|
||||
</span>
|
||||
<StatusBadge tone={workOrderStepTone(stepStatus)}>{workOrderStepStatusLabel(stepStatus)}</StatusBadge>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function WorkOrderStepIcon({ status, step }: { status: WorkOrderStepStatus; step: number }) {
|
||||
const className = cn(
|
||||
"grid size-6 place-items-center rounded-full",
|
||||
status === "done" && "bg-emerald-600 text-white",
|
||||
status === "current" && "bg-cyan-50 text-cyan-700 ring-2 ring-inset ring-cyan-500",
|
||||
status === "pending" && "bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200",
|
||||
status === "skipped" && "bg-slate-200 text-slate-500"
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-200/80 pt-2 text-[10px] text-slate-500">
|
||||
<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} />{formatTimestamp(item.scheduled_at)}</span> : null}
|
||||
<span className="font-mono">r{item.revision}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{process ? (
|
||||
<WorkOrderSubtasksPanel condition={process} />
|
||||
) : (
|
||||
<WorkbenchNotice tone="neutral" title="未提供过程">这条旧工单没有子任务与阶段数据,不根据总状态推测完成情况。</WorkbenchNotice>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
if (status === "done") return <span className={className}><CheckCircle2 size={13} /></span>;
|
||||
if (status === "current") return <span className={className}><CircleDot size={13} /></span>;
|
||||
if (status === "skipped") return <span className={className}><Ban size={12} /></span>;
|
||||
return <span className={className}><Clock3 size={12} /><span className="sr-only">步骤 {step}</span></span>;
|
||||
}
|
||||
|
||||
function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
|
||||
records: PeripheralTestDataRecord[];
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PeripheralTestDataRecord | null>(null);
|
||||
const [label, setLabel] = useState("");
|
||||
const [pointId, setPointId] = useState("DEMO-P001");
|
||||
const [observedAt, setObservedAt] = useState(toLocalInput(new Date()));
|
||||
const [value, setValue] = useState("");
|
||||
const [unit, setUnit] = useState("m");
|
||||
const [quality, setQuality] = useState<PeripheralTestDataRecord["quality"]>("valid");
|
||||
const clear = () => {
|
||||
setEditing(null);
|
||||
setEditorOpen(false);
|
||||
setLabel("");
|
||||
setPointId("DEMO-P001");
|
||||
setObservedAt(toLocalInput(new Date()));
|
||||
setValue("");
|
||||
setUnit("m");
|
||||
setQuality("valid");
|
||||
function createDefaultSubtask(): PeripheralSubtask {
|
||||
const scheduledAt = new Date(Date.now() + 60 * 60_000).toISOString();
|
||||
return {
|
||||
id: `subtask-${crypto.randomUUID()}`,
|
||||
title: "现场处置",
|
||||
summary: "按预约要求完成现场处置并回填结果。",
|
||||
status: "pending",
|
||||
scheduled_at: scheduledAt,
|
||||
location: "待填写位置",
|
||||
assignee: "待分配班组",
|
||||
duration_minutes: 60,
|
||||
reply_window_minutes: 30,
|
||||
stages: [
|
||||
{ id: "dispatch", sequence: 1, title: "派发", status: "pending", owner: "调度岗", time_label: "待安排", description: "确认班组、位置和预约窗口。" },
|
||||
{ id: "execution", sequence: 2, title: "执行", status: "pending", owner: "执行班组", time_label: "待安排", description: "按工单要求处置并记录现场结果。" },
|
||||
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "调度岗", time_label: "待安排", description: "核对回执要求并确认闭环。" }
|
||||
],
|
||||
reply_requirements: ["回填现场处理结果"]
|
||||
};
|
||||
const create = () => {
|
||||
clear();
|
||||
setEditorOpen(true);
|
||||
};
|
||||
const edit = (item: PeripheralTestDataRecord) => {
|
||||
setEditing(item);
|
||||
setEditorOpen(true);
|
||||
setLabel(item.label);
|
||||
setPointId(item.point_id);
|
||||
setObservedAt(toLocalInput(new Date(item.observed_at)));
|
||||
setValue(item.value === null ? "" : String(item.value));
|
||||
setUnit(item.unit);
|
||||
setQuality(item.quality);
|
||||
};
|
||||
|
||||
return <div>
|
||||
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
|
||||
<div className="mt-2"><DateTimeField label="观测时间" value={observedAt} onChange={setObservedAt} disabled={!canWrite || Boolean(busy)} /></div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">数值<Input className="mt-1 h-9" inputMode="decimal" value={value} onChange={(event) => setValue(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><FieldSelect label="质量" value={quality} onValueChange={(next) => setQuality(next as typeof quality)} disabled={!canWrite || Boolean(busy)} options={[{ value: "valid", label: "有效" }, { value: "unknown", label: "未知" }, { value: "missing", label: "缺失" }]} /></div>
|
||||
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !label.trim() || !pointId.trim() || !unit.trim() || (value !== "" && !Number.isFinite(Number(value)))} onClick={() => void mutate("save-data", async () => {
|
||||
const base = { label: label.trim(), point_id: pointId.trim(), observed_at: new Date(observedAt).toISOString(), value: value === "" ? null : Number(value), unit: unit.trim(), quality, notes: "外围样板手动测试数据" };
|
||||
if (editing) await client.updateTestData(editing.record_id, { expected_revision: editing.revision, clear_value: false, ...base });
|
||||
else await client.createTestData(base);
|
||||
clear();
|
||||
})}>{busy === "save-data" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建数据"}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
|
||||
</div> : null}
|
||||
<div className="mt-3 space-y-2">
|
||||
{records.length ? records.map((item) => <article key={item.record_id} className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200"><div className="flex items-start gap-2"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-1.5"><p className="truncate text-xs font-semibold text-slate-800">{item.label}</p><StatusBadge tone={qualityTone(item.quality)}>{qualityLabel(item.quality)}</StatusBadge></div><p className="mt-1 text-[11px] text-slate-500"><span className="font-medium text-slate-700">{item.point_id}</span> · <span className="tabular-nums">{item.value ?? "缺失"} {item.unit}</span> · r{item.revision}</p><p className="mt-1 text-[10px] text-slate-400">{formatTimestamp(item.observed_at)}</p></div><div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => edit(item)}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => void mutate(`delete-data-${item.record_id}`, async () => { await client.deleteTestData(item.record_id, item.revision); if (editing?.record_id === item.record_id) clear(); })}><Trash2 size={14} /></Button></div></div></article>) : <WorkbenchEmptyState icon={<Database size={15} />} title="暂无测试数据" description="新增记录后,可验证测点读数、缺失值和质量状态。" />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAdd }: {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return <div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1"><h4 className="text-xs font-semibold text-slate-800">{title}</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>
|
||||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={onAdd}><Plus size={13} />{actionLabel}</Button>
|
||||
</div>;
|
||||
function normalizeSubtask(subtask: PeripheralSubtask): PeripheralSubtask {
|
||||
return {
|
||||
...subtask,
|
||||
id: subtask.id.trim(),
|
||||
title: subtask.title.trim(),
|
||||
summary: subtask.summary.trim(),
|
||||
location: subtask.location.trim(),
|
||||
assignee: subtask.assignee.trim(),
|
||||
stages: subtask.stages.map((stage, index) => ({
|
||||
...stage,
|
||||
id: stage.id.trim(),
|
||||
sequence: index + 1,
|
||||
title: stage.title.trim(),
|
||||
owner: stage.owner.trim(),
|
||||
time_label: stage.time_label.trim(),
|
||||
description: stage.description.trim()
|
||||
})),
|
||||
reply_requirements: subtask.reply_requirements.map((item) => item.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function isValidSubtask(subtask: PeripheralSubtask) {
|
||||
return Boolean(
|
||||
subtask.id.trim()
|
||||
&& subtask.title.trim()
|
||||
&& subtask.summary.trim()
|
||||
&& subtask.location.trim()
|
||||
&& subtask.assignee.trim()
|
||||
&& subtask.duration_minutes > 0
|
||||
&& subtask.reply_window_minutes >= 0
|
||||
&& !Number.isNaN(Date.parse(subtask.scheduled_at))
|
||||
&& subtask.stages.length
|
||||
&& subtask.stages.every((stage) => stage.id.trim() && stage.title.trim() && stage.owner.trim() && stage.time_label.trim() && stage.description.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem | null {
|
||||
if (!item.subtasks?.length) return null;
|
||||
return {
|
||||
id: item.work_order_id,
|
||||
kind: "work_order",
|
||||
code: item.scenario_code ?? item.work_order_id,
|
||||
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),
|
||||
durationMinutes: item.subtasks.reduce((total, subtask) => total + subtask.duration_minutes, 0),
|
||||
detail: item.summary,
|
||||
source: "外围工单测试",
|
||||
dispatcher: item.updated_by,
|
||||
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
|
||||
dataSource: "backend",
|
||||
subtasks: item.subtasks.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.title,
|
||||
summary: subtask.summary,
|
||||
status: subtask.status,
|
||||
scheduledAt: subtask.scheduled_at,
|
||||
location: subtask.location,
|
||||
assignee: subtask.assignee,
|
||||
durationMinutes: subtask.duration_minutes,
|
||||
replyWindowMinutes: subtask.reply_window_minutes,
|
||||
stages: subtask.stages.map((stage) => ({ id: stage.id, sequence: stage.sequence, title: stage.title, status: stage.status, owner: stage.owner, timeLabel: stage.time_label, description: stage.description })),
|
||||
replyRequirements: subtask.reply_requirements
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
|
||||
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
|
||||
}
|
||||
|
||||
type ResourceProps = {
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
|
||||
client: PeripheralSampleClient;
|
||||
};
|
||||
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: "紧急" }];
|
||||
const SUBTASK_STATUS_OPTIONS = [{ value: "pending", label: "待处理" }, { value: "running", label: "处理中" }, { value: "blocked", label: "受阻" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }];
|
||||
const STAGE_STATUS_OPTIONS = [{ value: "pending", label: "待开始" }, { value: "current", label: "当前阶段" }, { value: "done", label: "已完成" }, { value: "blocked", label: "受阻" }, { value: "skipped", label: "已跳过" }];
|
||||
|
||||
function toLocalInput(value: Date) { const offset = value.getTimezoneOffset() * 60_000; return new Date(value.getTime() - offset).toISOString().slice(0, 16); }
|
||||
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC").slice(0, 19); }
|
||||
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
|
||||
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
|
||||
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
|
||||
function workOrderStepStatuses(status: PeripheralWorkOrder["status"]): [WorkOrderStepStatus, WorkOrderStepStatus, WorkOrderStepStatus] {
|
||||
if (status === "completed") return ["done", "done", "done"];
|
||||
if (status === "in_progress") return ["done", "current", "pending"];
|
||||
if (status === "open") return ["current", "pending", "pending"];
|
||||
if (status === "cancelled") return ["skipped", "skipped", "skipped"];
|
||||
return ["pending", "pending", "pending"];
|
||||
function toLocalInput(value: Date) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
function workOrderStepTone(status: WorkOrderStepStatus): StatusTone { return ({ done: "success", current: "info", pending: "neutral", skipped: "neutral" } satisfies Record<WorkOrderStepStatus, StatusTone>)[status]; }
|
||||
function workOrderStepStatusLabel(status: WorkOrderStepStatus) { return ({ done: "已完成", current: "当前步骤", pending: "待开始", skipped: "已跳过" } satisfies Record<WorkOrderStepStatus, string>)[status]; }
|
||||
function formatTimestamp(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
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 qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; }
|
||||
function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; }
|
||||
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约/维修", water_quality_complaint: "水质投诉", burst_report: "爆管上报" } satisfies Record<typeof value, string>)[value]; }
|
||||
|
||||
Reference in New Issue
Block a user