feat(workbench): refine dynamic task collaboration

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent b64d2b78bb
commit 87cc7785da
25 changed files with 1216 additions and 470 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
CalendarClock,
ClipboardList,
@@ -16,7 +16,7 @@ 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 { WorkOrderActivityPanel } from "../components/scheduled-condition-detail-panel";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
@@ -123,6 +123,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
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);
@@ -139,12 +143,34 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setProgressNote("");
};
const create = () => {
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) => {
const edit = (item: PeripheralWorkOrder, trigger: HTMLButtonElement) => {
editorTriggerRef.current = trigger;
setDeleteCandidateId(null);
setEditing(item);
setEditorOpen(true);
setTitle(item.title);
@@ -193,6 +219,81 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-slate-200 bg-white focus-visible:outline-0! focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]";
const orderEditor = editorOpen ? (
<section
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) {
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-slate-600 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-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={editorInputClassName} 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={editorInputClassName} 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={editorInputClassName} 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}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<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-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : 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
});
} 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,
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;
return (
<div>
@@ -205,7 +306,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<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}>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={(event) => create(event.currentTarget)}>
<Plus size={13} />新增预约工单
</Button>
</div>
@@ -238,68 +339,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</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}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<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-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : 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
});
} 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,
feedback_requirements: requirements
});
}
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}
{editorOpen && !editing ? orderEditor : null}
<section aria-label="工单列表" className="mt-3">
{orders.length ? (
@@ -311,9 +351,15 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
index={index}
canWrite={canWrite}
busy={busy}
onEdit={() => edit(item)}
onDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
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();
})}
/>
@@ -328,15 +374,25 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
);
}
function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, confirmingDelete, onRequestDelete, onCancelDelete, onConfirmDelete }: {
item: PeripheralWorkOrder;
index: number;
canWrite: boolean;
busy: string | null;
onEdit: () => void;
onDelete: () => void;
editing: boolean;
editor: ReactNode;
onEdit: (trigger: HTMLButtonElement) => void;
confirmingDelete: boolean;
onRequestDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const workOrder = toScheduledWorkOrder(item);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const cancelDelete = () => {
onCancelDelete();
deleteButtonRef.current?.focus();
};
return (
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<div className="flex items-start gap-2">
@@ -346,14 +402,40 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<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>
<h6 className="mt-1 text-balance 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>
{!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-slate-200/80 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
@@ -362,7 +444,7 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
<WorkOrderActivityPanel condition={workOrder} />
<WorkOrderReportPanel condition={workOrder} />
</div>
</article>
);
@@ -373,6 +455,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
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,
@@ -381,7 +465,7 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
updatedAt: Date.parse(item.updated_at),
detail: item.summary,
source: "外围工单测试",
dispatcher: item.updated_by,
dispatcher: "未记录",
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
location: item.location ?? undefined,
@@ -392,8 +476,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
};
}
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>;
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-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
}
const WORK_ORDER_STATUS_OPTIONS = [