refactor(workbench): simplify appointment tasks

This commit is contained in:
2026-09-24 12:01:11 +08:00
parent 436d5500f1
commit ddf953b641
28 changed files with 443 additions and 1384 deletions
@@ -156,29 +156,21 @@ const taskPermissionsSchema = z.object({
can_decide_commands: z.boolean(), can_decide_commands: z.boolean(),
can_execute_commands: z.boolean() can_execute_commands: z.boolean()
}).strict(); }).strict();
const externalWorkOrderStageSchema = z.object({ const externalWorkOrderActivitySchema = z.object({
id: z.string().min(1),
sequence: z.number().int().positive(), sequence: z.number().int().positive(),
occurred_at: workbenchDateTimeSchema,
actor: z.string().min(1),
kind: z.enum(["created", "updated", "feedback"]),
title: z.string().min(1), title: z.string().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]), detail: z.string().min(1)
owner: z.string().min(1),
time_label: z.string().min(1),
description: z.string().min(1)
}).strict(); }).strict();
const externalWorkOrderSubtaskSchema = z.object({ const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
id: z.string().min(1), if (value && typeof value === "object" && !Array.isArray(value)) {
title: z.string().min(1), const { subtasks: _legacySubtasks, ...flat } = value as Record<string, unknown>;
summary: z.string().min(1), return flat;
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]), }
scheduled_at: workbenchDateTimeSchema, return value;
location: z.string().min(1), }, z.object({
assignee: z.string().min(1),
duration_minutes: z.number().int().positive(),
reply_window_minutes: z.number().int().nonnegative(),
stages: z.array(externalWorkOrderStageSchema).min(1),
reply_requirements: z.array(z.string().min(1))
}).strict();
const externalWorkOrderSnapshotSchema = z.object({
work_order_id: z.string().uuid(), work_order_id: z.string().uuid(),
revision: z.number().int().positive(), revision: z.number().int().positive(),
title: z.string().min(1), title: z.string().min(1),
@@ -190,10 +182,11 @@ const externalWorkOrderSnapshotSchema = z.object({
work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]), work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]),
location: z.string().min(1).nullable(), location: z.string().min(1).nullable(),
scenario_code: z.string().min(1).nullable(), scenario_code: z.string().min(1).nullable(),
subtasks: z.array(externalWorkOrderSubtaskSchema).min(1).nullable(), feedback_requirements: z.array(z.string().min(1)).default([]),
activity: z.array(externalWorkOrderActivitySchema).default([]),
origin: z.literal("synthetic"), origin: z.literal("synthetic"),
updated_at: workbenchDateTimeSchema updated_at: workbenchDateTimeSchema
}).strict(); }).strict());
const operationalTaskSchema = taskSummarySchema.extend({ const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(), latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(), latest_report: reportRevisionSchema.nullable(),
@@ -260,47 +260,11 @@ describe("workbench runtime API client", () => {
source: "生产工单系统", source: "生产工单系统",
dispatcher: "调度中心", dispatcher: "调度中心",
priority: "urgent", priority: "urgent",
subtasks: [{ location: "北区阀门 V-1",
id: "wo-1-subtask-1", assignee: "北区班组",
title: "阀门开度复核", feedback_requirements: ["回传阀门开度"],
summary: "现场复核阀门开度。", activity: [{ sequence: 1, occurred_at: "2026-09-17T02:30:00Z", actor: "调度中心",
status: "completed", kind: "created", title: "工单创建", detail: "等待现场复核。" }]
scheduled_at: "2026-09-17T03:00:00Z",
location: "北区阀门 V-1",
assignee: "北区班组",
duration_minutes: 45,
reply_window_minutes: 20,
stages: [
{
id: "dispatch",
sequence: 1,
title: "派发",
status: "done",
owner: "调度中心",
time_label: "10:50",
description: "已派发。"
},
{
id: "execution",
sequence: 2,
title: "执行中",
status: "done",
owner: "北区班组",
time_label: "11:00—11:45",
description: "现场执行中。"
},
{
id: "reply",
sequence: 3,
title: "待复令",
status: "done",
owner: "北区班组",
time_label: "11:45—12:05",
description: "等待复令。"
}
],
reply_requirements: ["回传阀门开度"]
}]
}; };
const snapshot = parseWorkbenchRuntimeSnapshot({ const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot, ...runtimeSnapshot,
@@ -315,14 +279,10 @@ describe("workbench runtime API client", () => {
code: "WO-20260917-001", code: "WO-20260917-001",
status: "completed", status: "completed",
source: "生产工单系统", source: "生产工单系统",
subtasks: expect.arrayContaining([ location: "北区阀门 V-1",
expect.objectContaining({ assignee: "北区班组",
id: "wo-1-subtask-1", feedbackRequirements: ["回传阀门开度"],
stages: expect.arrayContaining([ activity: [expect.objectContaining({ title: "工单创建" })]
expect.objectContaining({ id: "execution", sequence: 2, timeLabel: "11:00—11:45" })
])
})
])
})]); })]);
}); });
@@ -87,50 +87,13 @@ const runtimeLatestAnalysisSchema = z.object({
map_payload: z.unknown().nullable() map_payload: z.unknown().nullable()
}); });
const runtimeWorkOrderStageSchema = z.object({ const runtimeWorkOrderActivitySchema = z.object({
id: z.string().trim().min(1),
sequence: z.number().int().positive(), sequence: z.number().int().positive(),
occurred_at: z.string().datetime({ offset: true }),
actor: z.string().trim().min(1),
kind: z.enum(["created", "updated", "feedback"]),
title: z.string().trim().min(1), title: z.string().trim().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]), detail: z.string().trim().min(1)
owner: z.string().trim().min(1),
time_label: z.string().trim().min(1),
description: z.string().trim().min(1)
});
const runtimeWorkOrderSubtaskSchema = z.object({
id: z.string().trim().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]),
scheduled_at: z.string().datetime({ offset: true }),
location: z.string().trim().min(1),
assignee: z.string().trim().min(1),
duration_minutes: z.number().int().positive(),
reply_window_minutes: z.number().int().positive(),
stages: z.array(runtimeWorkOrderStageSchema).min(1),
reply_requirements: z.array(z.string().trim().min(1))
}).superRefine((subtask, context) => {
if (subtask.stages.some((stage, index) => stage.sequence !== index + 1)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段序号必须从 1 连续递增",
path: ["stages"]
});
}
if (new Set(subtask.stages.map((stage) => stage.id)).size !== subtask.stages.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段编号不能重复",
path: ["stages"]
});
}
if (subtask.stages.filter((stage) => stage.status === "current").length > 1) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "单个子任务最多只能有一个当前阶段",
path: ["stages"]
});
}
}); });
const runtimeWorkOrderSchema = z.object({ const runtimeWorkOrderSchema = z.object({
@@ -147,16 +110,11 @@ const runtimeWorkOrderSchema = z.object({
duration_minutes: z.number().int().positive(), duration_minutes: z.number().int().positive(),
source: z.string().trim().min(1), source: z.string().trim().min(1),
dispatcher: z.string().trim().min(1), dispatcher: z.string().trim().min(1),
location: z.string().trim().min(1).nullable().default(null),
assignee: z.string().trim().min(1).nullable().default(null),
priority: z.enum(["normal", "urgent"]), priority: z.enum(["normal", "urgent"]),
subtasks: z.array(runtimeWorkOrderSubtaskSchema).min(1) feedback_requirements: z.array(z.string().trim().min(1)).default([]),
}).superRefine((workOrder, context) => { activity: z.array(runtimeWorkOrderActivitySchema).default([])
if (new Set(workOrder.subtasks.map((subtask) => subtask.id)).size !== workOrder.subtasks.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "工单子任务编号不能重复",
path: ["subtasks"]
});
}
}); });
const runtimeSnapshotSchema = z.object({ const runtimeSnapshotSchema = z.object({
@@ -278,26 +236,16 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
dispatcher: workOrder.dispatcher, dispatcher: workOrder.dispatcher,
priority: workOrder.priority, priority: workOrder.priority,
dataSource: "backend", dataSource: "backend",
subtasks: workOrder.subtasks.map((subtask) => ({ location: workOrder.location ?? undefined,
id: subtask.id, assignee: workOrder.assignee ?? undefined,
title: subtask.title, feedbackRequirements: workOrder.feedback_requirements,
summary: subtask.summary, activity: workOrder.activity.map((entry) => ({
status: subtask.status, sequence: entry.sequence,
scheduledAt: subtask.scheduled_at, occurredAt: entry.occurred_at,
location: subtask.location, actor: entry.actor,
assignee: subtask.assignee, kind: entry.kind,
durationMinutes: subtask.duration_minutes, title: entry.title,
replyWindowMinutes: subtask.reply_window_minutes, detail: entry.detail
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
})) }))
})) }))
}; };
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types"; import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import { ConditionDetailPanel, WorkOrderSubtasksPanel } from "./scheduled-condition-detail-panel"; import { ConditionDetailPanel, WorkOrderActivityPanel } from "./scheduled-condition-detail-panel";
import { createVisibleTickerCards } from "./agent-task-ticker-utils"; import { createVisibleTickerCards } from "./agent-task-ticker-utils";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts"; import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
@@ -34,7 +34,7 @@ it("does not infer water-quality progress or conversation steps from elapsed tim
describe("scheduled condition backend boundaries", () => { describe("scheduled condition backend boundaries", () => {
afterEach(cleanup); afterEach(cleanup);
it("keeps Chinese work-order time labels in the application font", () => { it("shows a flat work order with its recorded activity", () => {
const workOrder: ScheduledWorkOrderItem = { const workOrder: ScheduledWorkOrderItem = {
id: "demo-work-order", id: "demo-work-order",
kind: "work_order", kind: "work_order",
@@ -49,33 +49,17 @@ describe("scheduled condition backend boundaries", () => {
dispatcher: "调度岗", dispatcher: "调度岗",
priority: "normal", priority: "normal",
dataSource: "mock", dataSource: "mock",
subtasks: [{ location: "测试点",
id: "field-service", assignee: "维修班组",
title: "现场维修", feedbackRequirements: ["回填处理结果"],
summary: "按预约窗口处理", activity: [{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗",
status: "pending", kind: "created", title: "工单创建", detail: "确认预约窗口。" }]
scheduledAt: "2026-09-23T10:00:00Z",
location: "测试点",
assignee: "维修班组",
durationMinutes: 60,
replyWindowMinutes: 30,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "pending",
owner: "调度岗",
timeLabel: "待安排",
description: "确认预约窗口。"
}],
replyRequirements: ["回填处理结果"]
}]
}; };
render(<WorkOrderSubtasksPanel condition={workOrder} />); render(<WorkOrderActivityPanel condition={workOrder} />);
const timeLabel = screen.getByText("待安排", { exact: true }); expect(screen.getByText("工单创建")).toBeVisible();
expect(timeLabel).toHaveClass("tabular-nums"); expect(screen.getByText("回填处理结果")).toBeVisible();
expect(timeLabel).not.toHaveClass("font-mono"); expect(screen.queryByText("子任务")).not.toBeInTheDocument();
}); });
it("shows only persisted evidence for a failed backend condition", () => { it("shows only persisted evidence for a failed backend condition", () => {
@@ -54,10 +54,6 @@ import {
getStatusActivity, getStatusActivity,
getStatusTone, getStatusTone,
getRiskLabel, getRiskLabel,
getWorkOrderCurrentSubtask,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
statusLabels statusLabels
} from "./scheduled-condition-feed-utils"; } from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk"; import { getConditionReportRiskLevel } from "./condition-report-risk";
@@ -125,25 +121,19 @@ export function StatusPill({
condition: ScheduledConditionItem; condition: ScheduledConditionItem;
className?: string; className?: string;
}) { }) {
const isWorkOrder = condition.kind === "work_order";
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint"; const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
return ( return (
<StatusBadge <StatusBadge
tone={ tone={
isAgentEvent ? "neutral" : isWorkOrder isAgentEvent ? "neutral" : getStatusTone(condition.status)
? getWorkOrderStageTone(currentStage?.status)
: getStatusTone(condition.status)
} }
activity={ activity={
isAgentEvent ? "static" : isWorkOrder isAgentEvent ? "static" : getStatusActivity(condition.status)
? getWorkOrderStageActivity(currentStage?.status)
: getStatusActivity(condition.status)
} }
className={className} className={className}
> >
{isAgentEvent ? "事件调查" : isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]} {isAgentEvent ? "事件调查" : statusLabels[condition.status]}
</StatusBadge> </StatusBadge>
); );
} }
@@ -161,7 +151,7 @@ export function ConditionDetailPanel({
}: ConditionDetailPanelProps) { }: ConditionDetailPanelProps) {
const detailKey = const detailKey =
condition.kind === "work_order" condition.kind === "work_order"
? `${condition.id}-${getWorkOrderCurrentSubtask(condition)?.id ?? "none"}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}` ? `${condition.id}-${condition.updatedAt}-${condition.status}`
: condition.id; : condition.id;
const content = const content =
condition.kind === "work_order" ? ( condition.kind === "work_order" ? (
@@ -226,7 +216,7 @@ function ConditionRecordDetailPanel({
const header = getConditionDetailHeader(condition); const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel); const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`; const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`;
const externalWorkOrder = condition.operationalTask?.work_order_snapshot?.subtasks const externalWorkOrder = condition.operationalTask?.work_order_snapshot
? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot) ? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot)
: null; : null;
@@ -262,12 +252,12 @@ function ConditionRecordDetailPanel({
> >
{condition.operationalTask ? ( {condition.operationalTask ? (
<> <>
{externalWorkOrder ? <WorkOrderSubtasksPanel condition={externalWorkOrder} /> : null} {externalWorkOrder ? <WorkOrderActivityPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order" {condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot?.subtasks ? ( && !condition.operationalTask.work_order_snapshot ? (
<DetailSectionCard> <DetailSectionCard>
<SectionTitle icon={ListChecks} title="预约过程" /> <SectionTitle icon={ListChecks} title="预约过程" />
<p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单过程,不根据总状态推测阶段。</p> <p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单快照,不根据任务状态推测现场进展。</p>
</DetailSectionCard> </DetailSectionCard>
) : null} ) : null}
<AgentExecutionPlanPanel <AgentExecutionPlanPanel
@@ -456,9 +446,6 @@ function WorkOrderDetailPanel({
condition: ScheduledWorkOrderItem; condition: ScheduledWorkOrderItem;
dataSource: WorkbenchWorkOrderDataSource; dataSource: WorkbenchWorkOrderDataSource;
}) { }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const currentStage = getWorkOrderCurrentStage(condition);
return ( return (
<DetailScroll> <DetailScroll>
<DetailShell> <DetailShell>
@@ -471,17 +458,15 @@ function WorkOrderDetailPanel({
<SourceBoundaryNotice <SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "演示工单"} title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend" description={dataSource === "backend"
? "工单的子任务、阶段、责任人和复令要求均来自后端持久化记录。" ? "工单状态、负责人、回执要求与活动记录均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"} : "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
/> />
<WorkOrderSubtasksPanel condition={condition} /> <WorkOrderActivityPanel condition={condition} />
<DecisionActionStrip <DecisionActionStrip
icon={CalendarClock} icon={CalendarClock}
title={currentSubtask && currentStage title="工单跟踪"
? `${currentSubtask.title} · ${currentStage.title}` value={condition.recommendation ?? "按工单活动记录跟踪现场处置。"}
: "工单流转"} actionLabel={condition.status === "awaiting_reply" ? "待复令" : "跟踪工单"}
value={condition.recommendation ?? "按子任务及其阶段跟踪现场处置闭环。"}
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
actionDisabled actionDisabled
actionTitle="工单流程由调度和现场班组流转" actionTitle="工单流程由调度和现场班组流转"
/> />
@@ -1286,93 +1271,42 @@ function TaskExecutionStepRow({
); );
} }
export function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) { export function WorkOrderActivityPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const completedCount = condition.subtasks.filter((subtask) =>
subtask.status === "completed" || subtask.status === "cancelled"
).length;
return ( return (
<DetailSectionCard> <DetailSectionCard>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={ListChecks} title="子任务" /> <SectionTitle icon={ClipboardList} title="预约工单" />
<StatusBadge tone={completedCount === condition.subtasks.length ? "success" : "info"}> <StatusBadge tone={getStatusTone(condition.status)}>{statusLabels[condition.status]}</StatusBadge>
{completedCount}/{condition.subtasks.length} 完成
</StatusBadge>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4"> <div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
<ReportMetaTile label="来源" value={condition.source} /> <ReportMetaTile label="来源" value={condition.source} />
<ReportMetaTile label="派发人" value={condition.dispatcher} /> <ReportMetaTile label="负责人" value={condition.assignee ?? "待分配"} />
<ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} /> <ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<ReportMetaTile label="预计耗时" value={formatDuration(condition.durationMinutes, condition.status)} /> <ReportMetaTile label="预约时间" value={formatTime(condition.scheduledAt)} />
</div>
<div className="mt-2 grid gap-2">
{condition.subtasks.map((subtask, index) => {
const active = subtask.id === currentSubtask?.id;
const steps: ExecutionStep[] = subtask.stages.map((stage) => {
const awaitingReply = stage.id === "reply" && stage.status === "current";
return {
id: `${subtask.id}-${stage.id}`,
title: stage.title,
status: awaitingReply ? "confirmation" : stage.status,
statusLabel: awaitingReply ? "待复令" : undefined,
description: stage.description,
meta: [
{ label: "责任人", value: stage.owner },
{ label: "时间", value: stage.timeLabel }
]
};
});
return (
<Disclosure
key={subtask.id}
className="surface-control rounded-xl border border-slate-200/70 [&>summary]:px-3 [&>summary]:py-2.5"
initiallyOpen={active}
label={(
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 truncate">{index + 1}. {subtask.title}</span>
<StatusBadge
tone={getWorkOrderSubtaskTone(subtask.status)}
activity={subtask.status === "running" ? "live" : "static"}
>
{getWorkOrderSubtaskStatusLabel(subtask.status)}
</StatusBadge>
</span>
)}
>
<div className="border-t border-slate-200/70 px-3 py-3">
<p className="text-xs leading-5 text-slate-700">{subtask.summary}</p>
<div className="mt-2 grid gap-1 text-xs leading-5 text-slate-600 sm:grid-cols-2">
<span><b className="text-slate-400">位置:</b>{subtask.location}</span>
<span><b className="text-slate-400">班组:</b>{subtask.assignee}</span>
<span><b className="text-slate-400">窗口:</b><span className="font-mono">{formatTimeRange(subtask.scheduledAt, subtask.durationMinutes)}</span></span>
<span><b className="text-slate-400">复令:</b>完成后 {subtask.replyWindowMinutes} 分钟内</span>
</div>
<ol className="mt-3 grid gap-2">
{steps.map((step, stepIndex) => (
<TaskExecutionStepRow
key={step.id}
step={step}
isLast={stepIndex === steps.length - 1}
/>
))}
</ol>
{subtask.replyRequirements.length > 0 ? (
<ul className="mt-3 grid gap-1.5 border-t border-slate-200/70 pt-3">
{subtask.replyRequirements.map((item) => (
<li key={item} className="flex gap-2 text-xs leading-5 text-slate-700">
<CheckCircle2 className="mt-0.5 shrink-0 text-blue-600" size={14} />
<span>{item}</span>
</li>
))}
</ul>
) : null}
</div>
</Disclosure>
);
})}
</div> </div>
{condition.location ? <p className="mt-3 text-xs text-slate-600">位置:{condition.location}</p> : null}
{condition.feedbackRequirements.length ? (
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">回执要求</h4>
<ul className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => <li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700"><CheckCircle2 className="mt-0.5 shrink-0 text-blue-600" size={14} />{item}</li>)}
</ul>
</section>
) : null}
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">活动记录</h4>
{condition.activity.length ? (
<ol className="mt-2 border-l border-blue-200 pl-4">
{[...condition.activity].sort((a, b) => b.sequence - a.sequence).map((entry) => (
<li key={entry.sequence} className="relative pb-4 last:pb-0 before:absolute before:-left-[21px] before:top-1 before:h-2.5 before:w-2.5 before:rounded-full before:bg-blue-500">
<div className="flex flex-wrap items-baseline justify-between gap-1"><span className="text-xs font-semibold text-slate-800">{entry.title}</span><time className="text-[10px] text-slate-500">{formatTime(entry.occurredAt)}</time></div>
<p className="mt-1 text-xs leading-5 text-slate-600">{entry.detail}</p>
<p className="mt-1 text-[10px] text-slate-400">{entry.actor}</p>
</li>
))}
</ol>
) : <p className="mt-2 text-xs text-slate-500">暂无活动记录;不根据工单状态推测处置过程。</p>}
</section>
</DetailSectionCard> </DetailSectionCard>
); );
} }
@@ -1396,53 +1330,20 @@ function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): Sche
: "pending", : "pending",
riskLevel: snapshot.priority === "urgent" || snapshot.priority === "high" ? "attention" : "normal", riskLevel: snapshot.priority === "urgent" || snapshot.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(snapshot.updated_at), updatedAt: Date.parse(snapshot.updated_at),
durationMinutes: snapshot.subtasks?.reduce((total, item) => total + item.duration_minutes, 0),
detail: snapshot.summary, detail: snapshot.summary,
source: "外围工单系统", source: "外围工单系统",
dispatcher: "外围系统同步", dispatcher: "外围系统同步",
priority: snapshot.priority === "urgent" || snapshot.priority === "high" ? "urgent" : "normal", priority: snapshot.priority === "urgent" || snapshot.priority === "high" ? "urgent" : "normal",
dataSource: "backend", dataSource: "backend",
subtasks: (snapshot.subtasks ?? []).map((subtask) => ({ location: snapshot.location ?? undefined,
id: subtask.id, assignee: snapshot.assignee ?? undefined,
title: subtask.title, feedbackRequirements: snapshot.feedback_requirements,
summary: subtask.summary, activity: snapshot.activity.map((entry) => ({ sequence: entry.sequence,
status: subtask.status, occurredAt: entry.occurred_at, actor: entry.actor, kind: entry.kind,
scheduledAt: subtask.scheduled_at, title: entry.title, detail: entry.detail }))
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 getWorkOrderSubtaskTone(status: ScheduledWorkOrderItem["subtasks"][number]["status"]): StatusTone {
if (status === "completed") return "success";
if (status === "running") return "info";
if (status === "blocked") return "danger";
return "neutral";
}
function getWorkOrderSubtaskStatusLabel(status: ScheduledWorkOrderItem["subtasks"][number]["status"]) {
return {
pending: "待处理",
running: "处理中",
blocked: "受阻",
completed: "已完成",
cancelled: "已取消"
}[status];
}
function RunningEvidencePreview({ items }: { items: string[] }) { function RunningEvidencePreview({ items }: { items: string[] }) {
return ( return (
<DetailSectionCard> <DetailSectionCard>
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
groupScheduledConditions,
getStatusActivity, getStatusActivity,
getStatusTone, getStatusTone,
getWorkOrderStageActivity, sortConditionsNewestFirst
getWorkOrderStageTone,
getWorkOrderCurrentSubtask
} from "./scheduled-condition-feed-utils"; } from "./scheduled-condition-feed-utils";
import type { ScheduledWorkOrderItem } from "../types"; import type { ScheduledConditionItem, ScheduledWorkOrderItem } from "../types";
describe("scheduled condition status presentation", () => { describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => { it("maps lifecycle states to the shared semantic language", () => {
@@ -29,74 +28,73 @@ describe("scheduled condition status presentation", () => {
expect(getStatusActivity("awaiting_reply")).toBe("static"); expect(getStatusActivity("awaiting_reply")).toBe("static");
}); });
it("maps work-order stage states to the shared semantic language", () => { it("sorts mixed task representations by their full scheduled timestamp", () => {
expect({ const conditions = [
done: getWorkOrderStageTone("done"), createCondition("older-condition", "condition", "2026-09-17T10:00:00+08:00"),
current: getWorkOrderStageTone("current"), createCondition("newer-work-order", "work_order", "2026-09-18T09:00:00+08:00"),
pending: getWorkOrderStageTone("pending"), createCondition("newest-condition", "condition", "2026-09-18T11:00:00+08:00")
blocked: getWorkOrderStageTone("blocked"), ];
skipped: getWorkOrderStageTone("skipped")
}).toEqual({
done: "success",
current: "info",
pending: "neutral",
blocked: "danger",
skipped: "neutral"
});
expect(getWorkOrderStageActivity("current")).toBe("loading");
});
it("prefers an executing subtask over a pending subtask at its dispatch stage", () => { expect(sortConditionsNewestFirst(conditions).map((condition) => condition.id)).toEqual([
const workOrder = { "newest-condition",
id: "work-order-1", "newer-work-order",
kind: "work_order", "older-condition"
code: "WO-1", ]);
scheduledAt: "2026-09-17T10:00:00+08:00", const grouped = groupScheduledConditions(conditions);
title: "多子任务工单", expect(grouped.recentConditions.map((condition) => condition.id)).toEqual([
summary: "测试当前子任务选择。", "newest-condition",
status: "running", "older-condition"
riskLevel: "normal", ]);
updatedAt: 1, expect(grouped.futureWorkOrders.map((condition) => condition.id)).toEqual([
durationMinutes: 30, "newer-work-order"
source: "测试", ]);
dispatcher: "调度中心",
priority: "normal",
subtasks: [
createSubtask("pending-subtask", "pending", "dispatch"),
createSubtask("running-subtask", "running", "execution")
]
} satisfies ScheduledWorkOrderItem;
expect(getWorkOrderCurrentSubtask(workOrder)?.id).toBe("running-subtask");
}); });
}); });
function createSubtask( function createCondition(
id: string, id: string,
status: "pending" | "running", kind: ScheduledConditionItem["kind"],
currentStage: "dispatch" | "execution" scheduledAt: string
) { ): ScheduledConditionItem {
if (kind === "work_order") {
return {
...createWorkOrder(id),
scheduledAt
};
}
return { return {
id, id,
kind: "condition",
taskId: "scada-diagnosis",
sessionId: `session-${id}`,
title: id, title: id,
summary: id, summary: id,
status, status: "completed",
riskLevel: "normal",
scheduledAt,
updatedAt: Date.parse(scheduledAt)
};
}
function createWorkOrder(id: string): ScheduledWorkOrderItem {
return {
id,
kind: "work_order",
code: id,
scheduledAt: "2026-09-17T10:00:00+08:00", scheduledAt: "2026-09-17T10:00:00+08:00",
location: "演示点位", title: id,
assignee: "演示班组", summary: id,
durationMinutes: 15, status: "pending",
replyWindowMinutes: 10, riskLevel: "normal",
stages: [ updatedAt: 1,
{ durationMinutes: 30,
id: currentStage, source: "测试",
sequence: 1, dispatcher: "调度中心",
title: currentStage, priority: "normal",
status: "current" as const, location: "测试点",
owner: "演示班组", assignee: "测试班组",
timeLabel: "10:00", feedbackRequirements: [],
description: "当前阶段" activity: []
}
],
replyRequirements: []
}; };
} }
@@ -8,9 +8,7 @@ import {
import type { StatusActivity, StatusTone } from "@/shared/ui/status"; import type { StatusActivity, StatusTone } from "@/shared/ui/status";
import type { import type {
ScheduledConditionItem, ScheduledConditionItem,
ScheduledConditionItemStatus, ScheduledConditionItemStatus
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "../types"; } from "../types";
export const CONDITION_FILTER_ALL = "__all__"; export const CONDITION_FILTER_ALL = "__all__";
@@ -55,22 +53,6 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
pending: Clock3 pending: Clock3
}; };
const workOrderStageTones = {
done: "success",
current: "info",
pending: "neutral",
blocked: "danger",
skipped: "neutral"
} satisfies Record<ScheduledWorkOrderStage["status"], StatusTone>;
const workOrderStageActivities = {
done: "static",
current: "loading",
pending: "static",
blocked: "static",
skipped: "static"
} satisfies Record<ScheduledWorkOrderStage["status"], StatusActivity>;
const conditionStatusOrder: ScheduledConditionItemStatus[] = [ const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"running", "running",
"awaiting_reply", "awaiting_reply",
@@ -96,14 +78,14 @@ export type ConditionStatusFilterOption = {
count: number; count: number;
}; };
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) { export function sortConditionsNewestFirst(conditions: ScheduledConditionItem[]) {
const recentConditions = conditions return [...conditions].sort((a, b) => getConditionTime(b) - getConditionTime(a));
.filter((condition) => condition.kind === "condition") }
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
const futureWorkOrders = conditions export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
.filter((condition) => condition.kind === "work_order") const newestFirst = sortConditionsNewestFirst(conditions);
.sort((a, b) => getConditionTime(a) - getConditionTime(b)); const recentConditions = newestFirst.filter((condition) => condition.kind === "condition");
const futureWorkOrders = newestFirst.filter((condition) => condition.kind === "work_order");
return { recentConditions, futureWorkOrders }; return { recentConditions, futureWorkOrders };
} }
@@ -178,38 +160,6 @@ export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
return "正常"; return "正常";
} }
export function getWorkOrderCurrentSubtask(condition: ScheduledWorkOrderItem) {
return (
condition.subtasks.find((subtask) => subtask.status === "running") ??
condition.subtasks.find((subtask) => subtask.status === "blocked") ??
condition.subtasks.find((subtask) =>
subtask.stages.some((stage) => stage.status === "current")
) ??
condition.subtasks.find((subtask) => subtask.status === "pending") ??
condition.subtasks.at(-1) ??
null
);
}
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
const subtask = getWorkOrderCurrentSubtask(condition);
return (
subtask?.stages.find((stage) => stage.status === "current") ??
subtask?.stages.find((stage) => stage.status === "blocked") ??
subtask?.stages.find((stage) => stage.status === "pending") ??
subtask?.stages.at(-1) ??
null
);
}
export function getWorkOrderStageTone(status?: ScheduledWorkOrderStage["status"]) {
return status ? workOrderStageTones[status] : "neutral";
}
export function getWorkOrderStageActivity(status?: ScheduledWorkOrderStage["status"]) {
return status ? workOrderStageActivities[status] : "static";
}
export function getStatusIcon(status: ScheduledConditionItemStatus) { export function getStatusIcon(status: ScheduledConditionItemStatus) {
return statusIcons[status]; return statusIcons[status];
} }
@@ -46,14 +46,11 @@ import {
createStatusFilterOptions, createStatusFilterOptions,
createTaskFilterOptions, createTaskFilterOptions,
formatTime, formatTime,
getConditionTime,
getStatusActivity, getStatusActivity,
getStatusIcon, getStatusIcon,
getStatusTone, getStatusTone,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
groupScheduledConditions, groupScheduledConditions,
sortConditionsNewestFirst,
type ConditionStatusFilterOption, type ConditionStatusFilterOption,
type ConditionStatusFilterValue, type ConditionStatusFilterValue,
type ConditionTaskFilterOption, type ConditionTaskFilterOption,
@@ -171,10 +168,6 @@ export function ScheduledConditionFeed({
() => groupScheduledConditions(categoryConditions), () => groupScheduledConditions(categoryConditions),
[categoryConditions] [categoryConditions]
); );
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo( const taskFilterOptions = useMemo(
() => createTaskFilterOptions(categoryConditions), () => createTaskFilterOptions(categoryConditions),
[categoryConditions] [categoryConditions]
@@ -203,8 +196,10 @@ export function ScheduledConditionFeed({
[filteredConditions] [filteredConditions]
); );
const timelineConditions = useMemo( const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders], () => taskCategory === "appointment"
[futureWorkOrders, recentConditions] ? sortConditionsNewestFirst([...recentConditions, ...futureWorkOrders])
: [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions, taskCategory]
); );
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL; const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length; const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
@@ -214,8 +209,7 @@ export function ScheduledConditionFeed({
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`; : `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition = const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ?? timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ?? timelineConditions[0] ??
futureWorkOrders[0] ??
null; null;
const focusConditionId = focusRequest?.conditionId; const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId; const focusRequestId = focusRequest?.requestId;
@@ -294,7 +288,7 @@ export function ScheduledConditionFeed({
function handleToggleExpanded() { function handleToggleExpanded() {
updateExpanded(!expanded); updateExpanded(!expanded);
updateSelectedConditionId( updateSelectedConditionId(
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null selectedConditionId ?? timelineConditions[0]?.id ?? null
); );
} }
@@ -303,7 +297,9 @@ export function ScheduledConditionFeed({
if (nextCategory !== "scheduled") setScheduleOpen(false); if (nextCategory !== "scheduled") setScheduleOpen(false);
setTaskFilter(CONDITION_FILTER_ALL); setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null); updateSelectedConditionId(
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
} }
function handleToggleSchedule() { function handleToggleSchedule() {
@@ -562,19 +558,21 @@ export function ScheduledConditionFeed({
<ConditionTimelinePanel <ConditionTimelinePanel
expanded={expanded} expanded={expanded}
presentation={presentation} presentation={presentation}
loading={activeLoading} taskCategory={taskCategory}
emptyTitle={ loading={activeLoading}
backendError && taskCategory !== "scheduled" emptyTitle={
? `${WORKBENCH_TASK_CATEGORIES.find((item) => item.id === taskCategory)?.label ?? "后端任务"}暂不可用` backendError && taskCategory !== "scheduled"
: undefined ? `${WORKBENCH_TASK_CATEGORIES.find((item) => item.id === taskCategory)?.label ?? "后端任务"}暂不可用`
} : undefined
emptyDescription={ }
backendError && taskCategory !== "scheduled" emptyDescription={
? backendError backendError && taskCategory !== "scheduled"
: undefined ? backendError
} : undefined
}
recentConditions={recentConditions} recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders} futureWorkOrders={futureWorkOrders}
timelineConditions={timelineConditions}
taskFilter={taskFilter} taskFilter={taskFilter}
statusFilter={statusFilter} statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions} taskFilterOptions={taskFilterOptions}
@@ -648,9 +646,11 @@ function formatLocalDateTimeInput(value: Date) {
type ConditionTimelinePanelProps = { type ConditionTimelinePanelProps = {
expanded: boolean; expanded: boolean;
presentation: ScheduledConditionFeedPresentation; presentation: ScheduledConditionFeedPresentation;
taskCategory: WorkbenchTaskCategory;
loading: boolean; loading: boolean;
recentConditions: ScheduledConditionItem[]; recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[]; futureWorkOrders: ScheduledConditionItem[];
timelineConditions: ScheduledConditionItem[];
taskFilter: ConditionTaskFilterValue; taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue; statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[]; taskFilterOptions: ConditionTaskFilterOption[];
@@ -670,9 +670,11 @@ type ConditionTimelinePanelProps = {
function ConditionTimelinePanel({ function ConditionTimelinePanel({
expanded, expanded,
presentation, presentation,
taskCategory,
loading, loading,
recentConditions, recentConditions,
futureWorkOrders, futureWorkOrders,
timelineConditions,
taskFilter, taskFilter,
statusFilter, statusFilter,
taskFilterOptions, taskFilterOptions,
@@ -689,6 +691,7 @@ function ConditionTimelinePanel({
onSelectCondition onSelectCondition
}: ConditionTimelinePanelProps) { }: ConditionTimelinePanelProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL; const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const mainConditions = taskCategory === "appointment" ? timelineConditions : recentConditions;
return ( return (
<section <section
@@ -718,7 +721,10 @@ function ConditionTimelinePanel({
onResetFilters={onResetFilters} onResetFilters={onResetFilters}
/> />
) : null} ) : null}
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? ( {taskCategory !== "appointment"
&& (expanded || presentation === "mobile-sheet")
&& !loading
&& futureWorkOrders.length > 0 ? (
<section className="mb-2"> <section className="mb-2">
<ConditionGroupHeader <ConditionGroupHeader
icon={ClipboardList} icon={ClipboardList}
@@ -736,16 +742,16 @@ function ConditionTimelinePanel({
</section> </section>
) : null} ) : null}
<ConditionGroupHeader <ConditionGroupHeader
icon={History} icon={taskCategory === "appointment" ? ClipboardList : History}
title="最近工况" title="最近工况"
count={loading ? 0 : recentConditions.length} count={loading ? 0 : mainConditions.length}
/> />
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"> <div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? ( {loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} /> <ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
) : recentConditions.length > 0 ? ( ) : mainConditions.length > 0 ? (
<ConditionRows <ConditionRows
conditions={recentConditions} conditions={mainConditions}
selectedConditionId={selectedConditionId} selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition} onSelectCondition={onSelectCondition}
/> />
@@ -1078,18 +1084,8 @@ function ConditionTimelineRow({
onSelect onSelect
}: ConditionTimelineRowProps) { }: ConditionTimelineRowProps) {
const Icon = getStatusIcon(condition.status); const Icon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order"; const tone = getStatusTone(condition.status);
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.status : undefined; const activity = getStatusActivity(condition.status);
const tone = isWorkOrder
? condition.status === "running"
? getWorkOrderStageTone(workOrderStage)
: getStatusTone(condition.status)
: getStatusTone(condition.status);
const activity = isWorkOrder
? condition.status === "running"
? getWorkOrderStageActivity(workOrderStage)
: getStatusActivity(condition.status)
: getStatusActivity(condition.status);
return ( return (
<button <button
@@ -16,7 +16,7 @@ export const WORKBENCH_TASK_CATEGORIES = [
{ {
id: "appointment", id: "appointment",
label: "预约任务", label: "预约任务",
sourceLabel: "后端 / 演示" sourceLabel: "后端"
} }
] as const satisfies readonly { ] as const satisfies readonly {
id: WorkbenchTaskCategory; id: WorkbenchTaskCategory;
@@ -29,44 +29,13 @@ describe("createOperationalScheduledConditions", () => {
expect(currentCondition?.durationMinutes).toBe(5); expect(currentCondition?.durationMinutes).toBe(5);
}); });
it("labels the active work-order stage as executing", () => { it("leaves appointment orders to the backend projection", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00")); const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const executingWorkOrder = conditions.find( expect(conditions.every((condition) => condition.kind === "condition")).toBe(true);
(condition) =>
condition.kind === "work_order" &&
condition.subtasks.some((subtask) =>
subtask.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
)
)
);
expect(
executingWorkOrder?.kind === "work_order"
? executingWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "execution" && stage.status === "current")?.title
: undefined
).toBe("执行中");
}); });
it("moves completed field work into a waiting-for-reply state", () => { it("does not synthesize a waiting-for-reply appointment", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00")); const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const awaitingReplyWorkOrder = conditions.find( expect(conditions.some((condition) => condition.kind === "work_order")).toBe(false);
(condition) =>
condition.kind === "work_order" &&
condition.subtasks.some((subtask) =>
subtask.stages.some((stage) => stage.id === "reply" && stage.status === "current")
)
);
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
expect(
awaitingReplyWorkOrder?.kind === "work_order"
? awaitingReplyWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "reply" && stage.status === "current")?.title
: undefined
).toBe("待复令");
}); });
}); });
@@ -5,9 +5,7 @@ import type {
ScheduledConditionReport, ScheduledConditionReport,
ScheduledConditionRiskLevel, ScheduledConditionRiskLevel,
ScheduledConditionStatus, ScheduledConditionStatus,
ScheduledConditionTaskId, ScheduledConditionTaskId
ScheduledWorkOrderSubtask,
ScheduledWorkOrderStatus
} from "../types"; } from "../types";
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000; export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
@@ -18,8 +16,6 @@ const MODEL_NAME = "SupplyFlow 前端演示模拟器";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis"; const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId; type ConditionTaskId = ScheduledConditionTaskId;
type WorkOrderPhase = "dispatch" | "execution" | "reply";
type ConditionTaskDefinition = { type ConditionTaskDefinition = {
id: ConditionTaskId; id: ConditionTaskId;
title: string; title: string;
@@ -37,32 +33,6 @@ type TaskSignal = {
taskId: ConditionTaskId; taskId: ConditionTaskId;
}; };
type ManualWorkOrderDefinition = {
id: string;
title: string;
summary: string;
detail: string;
recommendation: string;
source: string;
location: string;
dispatcher: string;
assignee: string;
priority: "normal" | "urgent";
startMinute: number;
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
riskLevel: ScheduledConditionRiskLevel;
replyRequirements: string[];
};
type MockWorkOrderTemplate = Omit<ManualWorkOrderDefinition, "startMinute" | "durationMinutes" | "dispatchLeadMinutes" | "replyWindowMinutes"> & {
stage: "dispatch" | "execution" | "reply";
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
};
type DispatchInstruction = { type DispatchInstruction = {
action: string; action: string;
target: string; target: string;
@@ -101,120 +71,6 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
} }
]; ];
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
{
id: "mock-incident-valve-dispatch",
title: "演示工单:华山路阀门边界确认",
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
detail: "该条目是前端演示回退,用于展示人工确认界面,不代表真实提交或岗位通知。",
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
source: "异常事件分析",
location: "华山路沿线阀门组 VG-11",
dispatcher: "调度中心值班长",
assignee: "调度执行岗",
priority: "urgent",
durationMinutes: 40,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "dispatch",
replyRequirements: ["确认阀门当前开度", "记录影响分区和用户范围", "回传派发确认截图"]
},
{
id: "mock-valve-operation-execution",
title: "演示工单:北辰分区阀门开度调整",
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
source: "调度方案确认",
location: "北辰分区边界阀门 BV-07",
dispatcher: "管网调度岗",
assignee: "北辰抢修二组",
priority: "urgent",
durationMinutes: 55,
dispatchLeadMinutes: 25,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "execution",
replyRequirements: ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
},
{
id: "mock-pump-boundary-reply",
title: "演示工单:泵站边界复核复令",
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
source: "模型复核建议",
location: "北辰二级泵站 P-02",
dispatcher: "模型复核岗",
assignee: "泵站运行班组",
priority: "normal",
durationMinutes: 45,
dispatchLeadMinutes: 20,
replyWindowMinutes: 35,
riskLevel: "normal",
stage: "reply",
replyRequirements: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"]
}
];
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
{
id: "huashan-valve-well-service",
title: "演示人工工单:华山路阀井现场处置",
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
detail: "该条目模拟调度员根据现场巡检反馈创建人工工单,不代表真实提交或派发。",
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
source: "现场巡检反馈",
location: "华山路阀井",
dispatcher: "调度中心值班长",
assignee: "北片区抢修一组",
priority: "urgent",
startMinute: 9 * 60 + 40,
durationMinutes: 35,
dispatchLeadMinutes: 20,
replyWindowMinutes: 15,
riskLevel: "attention",
replyRequirements: ["上传处置前后照片", "记录阀井积水处理结果", "复核井盖密封和周边安全状态"]
},
{
id: "renmin-road-meter-change",
title: "演示人工工单:人民路流量计更换",
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
detail: "该条目模拟计量维护计划中的人工工单,执行前需确认旁通和作业窗口。",
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
source: "计量维护计划",
location: "人民路 DN300 支线",
dispatcher: "计量调度岗",
assignee: "计量维护二组",
priority: "normal",
startMinute: 10 * 60 + 30,
durationMinutes: 50,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "normal",
replyRequirements: ["登记新通信模块编号", "回传 15 分钟稳定性截图", "确认旁通恢复和计量数据连续"]
},
{
id: "beichen-fire-hydrant-repair",
title: "演示人工工单:北辰消防栓维修",
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
detail: "该条目模拟客服缺陷转派,由调度员排入当日维修窗口,不代表真实派发。",
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
source: "客服缺陷转派",
location: "北辰片区消防栓",
dispatcher: "客服联动调度",
assignee: "北辰维修班组",
priority: "urgent",
startMinute: 14 * 60,
durationMinutes: 60,
dispatchLeadMinutes: 45,
replyWindowMinutes: 20,
riskLevel: "attention",
replyRequirements: ["回填关停影响范围", "上传维修完成照片", "复测周边压力和消防栓密封状态"]
}
];
export function createOperationalScheduledConditions(now = new Date()): ScheduledConditionItem[] { export function createOperationalScheduledConditions(now = new Date()): ScheduledConditionItem[] {
const currentMinutesOfDay = getMinutesOfDay(now); const currentMinutesOfDay = getMinutesOfDay(now);
const historyStart = Math.max(0, currentMinutesOfDay - HISTORY_WINDOW_MINUTES); const historyStart = Math.max(0, currentMinutesOfDay - HISTORY_WINDOW_MINUTES);
@@ -226,9 +82,7 @@ export function createOperationalScheduledConditions(now = new Date()): Schedule
historicalConditions.some((condition) => condition.status === "running") || !runningCondition historicalConditions.some((condition) => condition.status === "running") || !runningCondition
? historicalConditions ? historicalConditions
: [runningCondition, ...historicalConditions.filter((condition) => condition.id !== runningCondition.id)]; : [runningCondition, ...historicalConditions.filter((condition) => condition.id !== runningCondition.id)];
const futureWorkOrders = createUpcomingWorkOrders(now, currentMinutesOfDay); return operationalConditions.sort(
return [...operationalConditions, ...futureWorkOrders].sort(
(a, b) => Date.parse(b.scheduledAt) - Date.parse(a.scheduledAt) (a, b) => Date.parse(b.scheduledAt) - Date.parse(a.scheduledAt)
); );
} }
@@ -326,202 +180,6 @@ function createGuaranteedRunningCondition(now: Date, currentMinutesOfDay: number
}; };
} }
function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): ScheduledConditionItem[] {
const workOrders = [...MANUAL_WORK_ORDERS, ...createMockWorkOrders(currentMinutesOfDay)];
return workOrders.filter(
(workOrder) => currentMinutesOfDay <= workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
)
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
return {
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
kind: "work_order" as const,
code: `WO-${formatCompactLocalTime(scheduledAtDate)}-${workOrder.id.toUpperCase().slice(0, 4)}`,
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
title: workOrder.title,
summary: workOrder.summary,
status: getWorkOrderStatus(phase),
riskLevel: workOrder.riskLevel,
updatedAt: now.getTime(),
detail: workOrder.detail,
recommendation: workOrder.recommendation,
durationMinutes: workOrder.durationMinutes,
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "mock" as const,
subtasks: createWorkOrderSubtasks(workOrder, now, scheduledAtDate, phase)
};
})
.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt));
}
function createMockWorkOrders(currentMinutesOfDay: number): ManualWorkOrderDefinition[] {
return MOCK_WORK_ORDER_TEMPLATES.map((template) => {
const startMinute = getMockWorkOrderStartMinute(template, currentMinutesOfDay);
return {
...template,
startMinute
};
});
}
function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMinutesOfDay: number) {
if (template.stage === "dispatch") {
return Math.min(currentMinutesOfDay + 30, 24 * 60 - 1);
}
if (template.stage === "execution") {
return Math.max(0, currentMinutesOfDay - Math.min(20, template.durationMinutes - 5));
}
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
}
function getWorkOrderPhase(
workOrder: ManualWorkOrderDefinition,
currentMinutesOfDay: number
): WorkOrderPhase {
if (currentMinutesOfDay < workOrder.startMinute) {
return "dispatch";
}
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
return "execution";
}
return "reply";
}
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
if (phase === "dispatch") {
return "pending";
}
if (phase === "execution") {
return "running";
}
return "awaiting_reply";
}
function createWorkOrderSubtasks(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
phase: WorkOrderPhase
): ScheduledWorkOrderSubtask[] {
const taskTitles = workOrder.replyRequirements.length > 0
? workOrder.replyRequirements
: [workOrder.title];
const activeIndex = phase === "dispatch"
? 0
: phase === "execution"
? Math.min(1, taskTitles.length - 1)
: taskTitles.length - 1;
const subtaskDuration = Math.max(5, Math.ceil(workOrder.durationMinutes / taskTitles.length));
return taskTitles.map((title, index) => {
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
const status = position === "before"
? "completed"
: position === "after" || phase === "dispatch"
? "pending"
: "running";
const subtaskScheduledAt = new Date(scheduledAtDate.getTime() + index * subtaskDuration * 60_000);
return {
id: `${workOrder.id}-subtask-${index + 1}`,
title,
summary: `完成“${title}”并保留可复核的阶段记录。`,
status,
scheduledAt: formatLocalIsoWithOffset(subtaskScheduledAt),
location: workOrder.location,
assignee: workOrder.assignee,
durationMinutes: subtaskDuration,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderSubtaskStages(
workOrder,
subtaskScheduledAt,
subtaskDuration,
phase,
position
),
replyRequirements: [`回传“${title}”的执行结果`]
};
});
}
function createWorkOrderSubtaskStages(
workOrder: ManualWorkOrderDefinition,
subtaskScheduledAt: Date,
subtaskDurationMinutes: number,
phase: WorkOrderPhase,
position: "before" | "active" | "after"
) {
const dispatchAt = new Date(
subtaskScheduledAt.getTime() - workOrder.dispatchLeadMinutes * 60_000
);
const executionEnd = new Date(
subtaskScheduledAt.getTime() + subtaskDurationMinutes * 60_000
);
const replyEnd = new Date(executionEnd.getTime() + workOrder.replyWindowMinutes * 60_000);
const allDone = position === "before";
const allPending = position === "after";
return [
{
id: "dispatch",
sequence: 1,
title: "派发",
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "dispatch"
? ("current" as const)
: ("done" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution",
sequence: 2,
title: "执行中",
status:
allDone
? ("done" as const)
: allPending || phase === "dispatch"
? ("pending" as const)
: phase === "execution"
? ("current" as const)
: ("done" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(subtaskScheduledAt, subtaskDurationMinutes),
description: workOrder.summary
},
{
id: "reply",
sequence: 3,
title: "待复令",
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "reply"
? ("current" as const)
: ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
}
];
}
function getConditionStatus( function getConditionStatus(
task: ConditionTaskDefinition, task: ConditionTaskDefinition,
scheduledAt: Date, scheduledAt: Date,
@@ -1146,10 +804,6 @@ function formatClock(date: Date) {
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
} }
function formatClockRange(start: Date, durationMinutes: number) {
const end = new Date(start.getTime() + durationMinutes * 60_000);
return `${formatClock(start)} - ${formatClock(end)}`;
}
function formatCompactLocalTime(date: Date) { function formatCompactLocalTime(date: Date) {
const yyyy = date.getFullYear().toString(); const yyyy = date.getFullYear().toString();
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { OperationalTask } from "../api/operational-task-client"; import type { OperationalTask } from "../api/operational-task-client";
import { toOperationalTaskCondition } from "./use-operational-tasks"; import type { WorkbenchEvent } from "../api/workbench-sync-client";
import {
collectOperationalTaskEventChanges,
toOperationalTaskCondition
} from "./use-operational-tasks";
function task(taskType: OperationalTask["task_type"]): OperationalTask { function task(taskType: OperationalTask["task_type"]): OperationalTask {
return { return {
@@ -69,7 +73,8 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
work_order_type: "general", work_order_type: "general",
location: "测试点", location: "测试点",
scenario_code: null, scenario_code: null,
subtasks: null, feedback_requirements: [],
activity: [],
origin: "synthetic", origin: "synthetic",
updated_at: "2026-09-23T09:00:00Z" updated_at: "2026-09-23T09:00:00Z"
} }
@@ -99,3 +104,38 @@ describe("operational task condition mapper", () => {
expect(toOperationalTaskCondition(task("event_investigation")).detail).toBe("初次同步时的旧描述"); expect(toOperationalTaskCondition(task("event_investigation")).detail).toBe("初次同步时的旧描述");
}); });
}); });
describe("operational task event invalidation", () => {
it("removes deleted tasks without requesting their missing details", () => {
const deletedTaskId = "22222222-2222-4222-8222-222222222222";
const changedTaskId = "33333333-3333-4333-8333-333333333333";
const changes = collectOperationalTaskEventChanges([
event(1, deletedTaskId, { change: "deleted" }),
event(2, deletedTaskId, { task_id: deletedTaskId }),
event(3, changedTaskId, { task_id: changedTaskId })
]);
expect([...changes.deletedTaskIds]).toEqual([deletedTaskId]);
expect([...changes.taskIds]).toEqual([changedTaskId]);
expect(changes.notificationsChanged).toBe(true);
});
});
function event(
eventSequence: number,
taskId: string,
metadata: Record<string, unknown>
): WorkbenchEvent {
return {
schema: "workbench-event@1",
event_seq: eventSequence,
event_id: `${eventSequence.toString().padStart(8, "0")}-0000-4000-8000-000000000000`,
project_id: "11111111-1111-4111-8111-111111111111",
topic: "operational.task.changed",
aggregate_type: "operational_task",
aggregate_id: taskId,
aggregate_revision: 1,
occurred_at: "2026-09-24T00:00:00Z",
metadata
};
}
@@ -15,6 +15,7 @@ import {
type OperationalTaskNotification type OperationalTaskNotification
} from "../api/operational-task-client"; } from "../api/operational-task-client";
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types"; import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchSyncState } from "./use-workbench-sync"; import type { WorkbenchSyncState } from "./use-workbench-sync";
@@ -30,6 +31,31 @@ export type CreateInvestigationInput = {
triggerKind?: "event" | "manual"; triggerKind?: "event" | "manual";
}; };
export function collectOperationalTaskEventChanges(events: WorkbenchEvent[]) {
const taskIds = new Set<string>();
const deletedTaskIds = new Set<string>();
let notificationsChanged = false;
for (const event of events) {
if (event.topic === "operational.notification.changed") {
notificationsChanged = true;
continue;
}
if (!event.topic.startsWith("operational.")) continue;
const taskId = typeof event.metadata.task_id === "string"
? event.metadata.task_id
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
if (!taskId) continue;
if (event.topic === "operational.task.changed" && event.metadata.change === "deleted") {
taskIds.delete(taskId);
deletedTaskIds.add(taskId);
notificationsChanged = true;
} else if (!deletedTaskIds.has(taskId)) {
taskIds.add(taskId);
}
}
return { taskIds, deletedTaskIds, notificationsChanged };
}
export function useOperationalTasks( export function useOperationalTasks(
getAccessToken: AccessTokenProvider | undefined, getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState sync: WorkbenchSyncState
@@ -99,15 +125,11 @@ export function useOperationalTasks(
if (events.length === 0) return; if (events.length === 0) return;
const controller = new AbortController(); const controller = new AbortController();
const lastSequence = events.at(-1)!.event_seq; const lastSequence = events.at(-1)!.event_seq;
const taskIds = new Set<string>(); const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
let notificationsChanged = false; if (deletedTaskIds.size > 0) {
for (const event of events) { tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
if (event.topic === "operational.notification.changed") notificationsChanged = true; setTasks(tasksRef.current);
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue; setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
const taskId = typeof event.metadata.task_id === "string"
? event.metadata.task_id
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
if (taskId) taskIds.add(taskId);
} }
void Promise.all([ void Promise.all([
notificationsChanged notificationsChanged
@@ -47,27 +47,10 @@ const mockWorkOrder: ScheduledWorkOrderItem = {
source: "演示", source: "演示",
dispatcher: "调度中心", dispatcher: "调度中心",
priority: "normal", priority: "normal",
subtasks: [{ location: "V-1",
id: "mock-subtask-1", assignee: "现场班组",
title: "现场复核", feedbackRequirements: ["回传设备状态"],
summary: "复核设备状态并回传。", activity: []
status: "pending",
scheduledAt: "2026-09-17T11:00:00+08:00",
location: "V-1",
assignee: "现场班组",
durationMinutes: 30,
replyWindowMinutes: 15,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "current",
owner: "调度中心",
timeLabel: "10:45",
description: "派发现场复核任务。"
}],
replyRequirements: ["回传设备状态"]
}]
}; };
describe("workbench runtime data", () => { describe("workbench runtime data", () => {
@@ -109,13 +92,13 @@ describe("workbench runtime data", () => {
expect(resolved.workOrderDataSource).toBe("none"); expect(resolved.workOrderDataSource).toBe("none");
}); });
it("keeps explicit demo work orders visible without treating them as backend fallback", () => { it("does not use frontend demo work orders as a backend fallback", () => {
const demoWorkOrder = { ...mockWorkOrder, dataSource: "mock" as const }; const demoWorkOrder = { ...mockWorkOrder, dataSource: "mock" as const };
const resolved = resolveWorkbenchRuntimeData([mockCondition, demoWorkOrder], null, { const resolved = resolveWorkbenchRuntimeData([mockCondition, demoWorkOrder], null, {
operationalDate: SEPTEMBER_17 operationalDate: SEPTEMBER_17
}); });
expect(resolved.scheduledConditions).toEqual([mockCondition, demoWorkOrder]); expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workOrderDataSource).toBe("none"); expect(resolved.workOrderDataSource).toBe("none");
}); });
@@ -276,7 +259,7 @@ describe("workbench runtime data", () => {
expect(resolved.runtimeDataSource).toBe("mock"); expect(resolved.runtimeDataSource).toBe("mock");
}); });
it("keeps explicit demo appointments when an explicit backend day has no work orders", () => { it("treats an empty configured backend work-order partition as authoritative", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], { const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "ready", availability: "ready",
source: "none", source: "none",
@@ -294,7 +277,7 @@ describe("workbench runtime data", () => {
workOrders: [] workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" }); }, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder]); expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock"); expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend"); expect(resolved.workOrderDataSource).toBe("backend");
}); });
@@ -379,7 +362,7 @@ describe("workbench runtime data", () => {
expect(resolved.workbenchAlerts).toEqual([backendAlert]); expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend"); expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder, backendWorkOrder]); expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend"); expect(resolved.workOrderDataSource).toBe("backend");
}); });
}); });
@@ -80,10 +80,6 @@ export function resolveWorkbenchRuntimeData(
(condition) => condition.kind === "condition" (condition) => condition.kind === "condition"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate) && isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
); );
const demoWorkOrders = mockConditions.filter(
(condition) => condition.kind === "work_order"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter( const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate) (workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
); );
@@ -107,7 +103,7 @@ export function resolveWorkbenchRuntimeData(
); );
return { return {
scheduledConditions: [...fallbackConditions, ...demoWorkOrders, ...resolvedWorkOrders], scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts ? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions), : createScheduledConditionAlerts(fallbackConditions),
@@ -26,27 +26,11 @@ const workOrder: ScheduledConditionItem = {
source: "调度方案", source: "调度方案",
dispatcher: "调度", dispatcher: "调度",
priority: "urgent", priority: "urgent",
subtasks: [{ location: "边界阀门 BV-07",
id: "work-1-subtask-1", assignee: "班组",
title: "调整阀门开度", feedbackRequirements: ["回传阀门开度"],
summary: "现场调整并记录开度。", activity: [{ sequence: 1, occurredAt: "2026-08-19T10:00:00.000Z", actor: "班组",
status: "running", kind: "updated", title: "现场反馈", detail: "现场调整阀门。" }]
scheduledAt: "2026-08-19T10:00:00.000Z",
location: "边界阀门 BV-07",
assignee: "班组",
durationMinutes: 40,
replyWindowMinutes: 20,
stages: [{
id: "execution",
sequence: 1,
title: "执行",
status: "current",
owner: "班组",
timeLabel: "10:00 - 10:40",
description: "现场调整阀门。"
}],
replyRequirements: ["回传阀门开度"]
}]
}; };
describe("operational timeline model", () => { describe("operational timeline model", () => {
@@ -60,8 +44,8 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId); expect(events[0].correlationId).toBe(events[1].correlationId);
}); });
it("does not invent actual execution times for backend work orders", () => { it("does not invent actual execution times without activity", () => {
const events = createOperationalEvents([{ ...workOrder, dataSource: "backend" }]); const events = createOperationalEvents([{ ...workOrder, dataSource: "backend", activity: [] }]);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ lane: "plan", actualTime: null }); expect(events[0]).toMatchObject({ lane: "plan", actualTime: null });
@@ -195,13 +195,10 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
} }
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] { function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const subtask = getRepresentativeWorkOrderSubtask(item);
const planEvent: OperationalEvent = { const planEvent: OperationalEvent = {
id: `plan-${item.id}`, id: `plan-${item.id}`,
title: item.title, title: item.title,
description: subtask description: [item.summary, item.location, item.assignee].filter(Boolean).join(" · "),
? `${subtask.title} · ${subtask.location} · ${subtask.assignee}`
: item.summary,
lane: "plan", lane: "plan",
status: "planned", status: "planned",
importance: item.priority === "urgent" ? "important" : "normal", importance: item.priority === "urgent" ? "important" : "normal",
@@ -215,32 +212,16 @@ function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[]
mapTargets: getWorkOrderMapTargets(item) mapTargets: getWorkOrderMapTargets(item)
}; };
if (item.dataSource === "backend") { return [planEvent, ...item.activity.map((entry): OperationalEvent => ({
return [planEvent];
}
if (item.status === "pending") {
return [planEvent];
}
const start = Date.parse(item.scheduledAt);
const actualTime = new Date(
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
).toISOString();
const actualEvent: OperationalEvent = {
...planEvent, ...planEvent,
id: `actual-${item.id}`, id: `activity-${item.id}-${entry.sequence}`,
title: entry.title,
lane: "actual", lane: "actual",
status: "executed", status: "executed",
actualTime, actualTime: entry.occurredAt,
cursorTime: actualTime, cursorTime: entry.occurredAt,
description: description: entry.detail
item.status === "running" }))];
? `${subtask?.assignee ?? "执行班组"}正在执行,位置:${subtask?.location ?? "待确认"}`
: `${subtask?.assignee ?? "执行班组"}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
} }
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] { function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
@@ -254,32 +235,10 @@ function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[]
} }
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] { function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
const targets = item.subtasks.flatMap((subtask): FeatureTarget[] => { const location = item.location ?? "";
if (subtask.location.includes("阀")) { if (location.includes("阀")) return [{ sourceId: "valves", featureId: extractAssetId(location) }];
return [{ sourceId: "valves", featureId: extractAssetId(subtask.location) }]; if (location.includes("流量计")) return [{ sourceId: "scada", featureId: extractAssetId(location) }];
} return [];
if (subtask.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(subtask.location) }];
}
return [];
});
return targets.filter((target, index) =>
targets.findIndex((candidate) =>
candidate.sourceId === target.sourceId && candidate.featureId === target.featureId
) === index
);
}
function getRepresentativeWorkOrderSubtask(item: ScheduledWorkOrderItem) {
return item.subtasks.find((subtask) => subtask.status === "running")
?? item.subtasks.find((subtask) => subtask.status === "blocked")
?? item.subtasks.find((subtask) =>
subtask.stages.some((stage) => stage.status === "current")
)
?? item.subtasks.find((subtask) => subtask.status === "pending")
?? item.subtasks.at(-1)
?? null;
} }
function extractAssetId(location: string) { function extractAssetId(location: string) {
@@ -37,6 +37,8 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
updated_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
created_by: "user", created_by: "user",
updated_by: "user", updated_by: "user",
feedback_requirements: [],
activity: [],
...overrides ...overrides
}); });
@@ -49,7 +51,8 @@ const createWorkOrderInput: Omit<PeripheralCreateWorkOrder, "request_id"> = {
scheduled_at: null, scheduled_at: null,
work_order_type: "general", work_order_type: "general",
location: null, location: null,
scenario_code: null scenario_code: null,
feedback_requirements: []
}; };
describe("PeripheralSampleClient", () => { describe("PeripheralSampleClient", () => {
@@ -182,7 +185,7 @@ describe("PeripheralSampleClient", () => {
clear_location: false, clear_location: false,
clear_scenario_code: false, clear_scenario_code: false,
clear_scheduled_at: false, clear_scheduled_at: false,
clear_subtasks: false feedback_requirements: []
})).resolves.toEqual(order); })).resolves.toEqual(order);
await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted); await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted);
@@ -325,6 +325,8 @@ export interface components {
CreateWorkOrder: { CreateWorkOrder: {
/** Assignee */ /** Assignee */
assignee?: string | null; assignee?: string | null;
/** Feedback Requirements */
feedback_requirements?: string[];
/** Location */ /** Location */
location?: string | null; location?: string | null;
/** /**
@@ -348,8 +350,6 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled"; status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */ /** Summary */
summary: string; summary: string;
/** Title */ /** Title */
@@ -914,17 +914,16 @@ export interface components {
* @default false * @default false
*/ */
clear_scheduled_at: boolean; clear_scheduled_at: boolean;
/**
* Clear Subtasks
* @default false
*/
clear_subtasks: boolean;
/** Expected Revision */ /** Expected Revision */
expected_revision: number; expected_revision: number;
/** Feedback Requirements */
feedback_requirements?: string[] | null;
/** Location */ /** Location */
location?: string | null; location?: string | null;
/** Priority */ /** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null; priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Progress Note */
progress_note?: string | null;
/** /**
* Request Id * Request Id
* Format: uuid * Format: uuid
@@ -936,8 +935,6 @@ export interface components {
scheduled_at?: string | null; scheduled_at?: string | null;
/** Status */ /** Status */
status?: ("draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled") | null; status?: ("draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled") | null;
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */ /** Summary */
summary?: string | null; summary?: string | null;
/** Title */ /** Title */
@@ -945,6 +942,8 @@ export interface components {
}; };
/** WorkOrder */ /** WorkOrder */
WorkOrder: { WorkOrder: {
/** Activity */
activity?: components["schemas"]["WorkOrderActivity"][];
/** Assignee */ /** Assignee */
assignee: string | null; assignee: string | null;
/** /**
@@ -954,6 +953,8 @@ export interface components {
created_at: string; created_at: string;
/** Created By */ /** Created By */
created_by: string; created_by: string;
/** Feedback Requirements */
feedback_requirements?: string[];
/** Location */ /** Location */
location?: string | null; location?: string | null;
/** /**
@@ -982,8 +983,6 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled"; status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */ /** Summary */
summary: string; summary: string;
/** Title */ /** Title */
@@ -1007,6 +1006,27 @@ export interface components {
*/ */
work_order_type: "general" | "water_quality_complaint" | "burst_report"; work_order_type: "general" | "water_quality_complaint" | "burst_report";
}; };
/** WorkOrderActivity */
WorkOrderActivity: {
/** Actor */
actor: string;
/** Detail */
detail: string;
/**
* Kind
* @enum {string}
*/
kind: "created" | "updated" | "feedback";
/**
* Occurred At
* Format: date-time
*/
occurred_at: string;
/** Sequence */
sequence: number;
/** Title */
title: string;
};
/** WorkOrderPage */ /** WorkOrderPage */
WorkOrderPage: { WorkOrderPage: {
/** Items */ /** Items */
@@ -1014,57 +1034,6 @@ export interface components {
/** Next Cursor */ /** Next Cursor */
next_cursor: string | null; next_cursor: string | null;
}; };
/** WorkbenchRuntimeWorkOrderStage */
WorkbenchRuntimeWorkOrderStage: {
/** Description */
description: string;
/** Id */
id: string;
/** Owner */
owner: string;
/** Sequence */
sequence: number;
/**
* Status
* @enum {string}
*/
status: "done" | "current" | "pending" | "blocked" | "skipped";
/** Time Label */
time_label: string;
/** Title */
title: string;
};
/** WorkbenchRuntimeWorkOrderSubtask */
WorkbenchRuntimeWorkOrderSubtask: {
/** Assignee */
assignee: string;
/** Duration Minutes */
duration_minutes: number;
/** Id */
id: string;
/** Location */
location: string;
/** Reply Requirements */
reply_requirements: string[];
/** Reply Window Minutes */
reply_window_minutes: number;
/**
* Scheduled At
* Format: date-time
*/
scheduled_at: string;
/** Stages */
stages: components["schemas"]["WorkbenchRuntimeWorkOrderStage"][];
/**
* Status
* @enum {string}
*/
status: "pending" | "running" | "blocked" | "completed" | "cancelled";
/** Summary */
summary: string;
/** Title */
title: string;
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@@ -12,12 +12,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field"; import { DateTimeField } from "@/shared/ui/date-time-field";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select"; import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status"; import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderSubtasksPanel } from "../components/scheduled-condition-detail-panel"; import { WorkOrderActivityPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui"; import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types"; import type { ScheduledWorkOrderItem } from "../types";
import type { import type {
@@ -26,9 +25,6 @@ import type {
PeripheralWorkOrder PeripheralWorkOrder
} from "./client"; } from "./client";
type PeripheralSubtask = NonNullable<PeripheralWorkOrder["subtasks"]>[number];
type PeripheralStage = PeripheralSubtask["stages"][number];
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: { export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
client: PeripheralSampleClient; client: PeripheralSampleClient;
canWrite: boolean; canWrite: boolean;
@@ -72,7 +68,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
return ( return (
<WorkbenchSection <WorkbenchSection
title="工单接口测试" title="工单接口测试"
description="验证创建、编辑、过程状态、反馈回填与后端同步。" description="验证单层预约工单、现场反馈与后端同步。"
icon={<ClipboardList size={15} />} icon={<ClipboardList size={15} />}
tone="teal" tone="teal"
bodyClassName="space-y-3" bodyClassName="space-y-3"
@@ -119,7 +115,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [scenarioCode, setScenarioCode] = useState(""); const [scenarioCode, setScenarioCode] = useState("");
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000))); const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [subtasks, setSubtasks] = useState<PeripheralSubtask[] | null>(null); const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false); const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint"); const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState(""); const [eventLocation, setEventLocation] = useState("");
@@ -138,13 +135,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setLocation(""); setLocation("");
setScenarioCode(""); setScenarioCode("");
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000))); setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setSubtasks(null); setFeedbackRequirements("");
setProgressNote("");
}; };
const create = () => { const create = () => {
clear(); clear();
setEditorOpen(true); setEditorOpen(true);
setSubtasks([createDefaultSubtask()]);
}; };
const edit = (item: PeripheralWorkOrder) => { const edit = (item: PeripheralWorkOrder) => {
@@ -158,7 +155,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setLocation(item.location ?? ""); setLocation(item.location ?? "");
setScenarioCode(item.scenario_code ?? ""); setScenarioCode(item.scenario_code ?? "");
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : ""); setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setSubtasks(item.subtasks ?? null); setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
setProgressNote("");
}; };
const triggerAgent = async (workOrder: PeripheralWorkOrder) => { const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
@@ -186,7 +184,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
work_order_type: eventKind, work_order_type: eventKind,
location: eventLocation.trim(), location: eventLocation.trim(),
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001", scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
subtasks: null feedback_requirements: []
}); });
setEventEditorOpen(false); setEventEditorOpen(false);
setEventLocation(""); setEventLocation("");
@@ -194,15 +192,14 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
await triggerAgent(workOrder); await triggerAgent(workOrder);
}); });
const processValid = Boolean(subtasks?.length && subtasks.every(isValidSubtask)); const canSave = canWrite && !busy && title.trim() && summary.trim();
const canSave = canWrite && !busy && title.trim() && summary.trim() && processValid;
return ( return (
<div> <div>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h4 className="text-xs font-semibold text-slate-800">测试工单</h4> <h4 className="text-xs font-semibold text-slate-800">测试工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">过程字段将随工单写入,并按原结构同步到预约任务。</p> <p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张预约工单对应一条任务;修改和现场反馈按时间记录。</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}> <Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
@@ -256,11 +253,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div> </div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null} {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} /> <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"> <div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => { <Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
const normalizedSubtasks = subtasks!.map(normalizeSubtask); const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
if (editing) { if (editing) {
await client.updateWorkOrder(editing.work_order_id, { await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision, expected_revision: editing.revision,
@@ -276,8 +274,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
clear_location: !location.trim(), clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null, scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(), clear_scenario_code: !scenarioCode.trim(),
subtasks: normalizedSubtasks, feedback_requirements: requirements,
clear_subtasks: false progress_note: progressNote.trim() || null
}); });
} else { } else {
await client.createWorkOrder({ await client.createWorkOrder({
@@ -290,13 +288,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
work_order_type: "general", work_order_type: "general",
location: location.trim() || null, location: location.trim() || null,
scenario_code: scenarioCode.trim() || null, scenario_code: scenarioCode.trim() || null,
subtasks: normalizedSubtasks feedback_requirements: requirements
}); });
} }
clear(); clear();
})}> })}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />} {busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单与过程" : "创建预约工单"} {editing ? "保存工单" : "创建预约工单"}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button> <Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button>
</div> </div>
@@ -330,89 +328,6 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
); );
} }
function ProcessEditor({ subtasks, disabled, onChange }: {
subtasks: PeripheralSubtask[] | null;
disabled: boolean;
onChange: (subtasks: PeripheralSubtask[] | null) => void;
}) {
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 }: { function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
item: PeripheralWorkOrder; item: PeripheralWorkOrder;
index: number; index: number;
@@ -421,7 +336,7 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const process = toScheduledWorkOrder(item); const workOrder = toScheduledWorkOrder(item);
return ( return (
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200"> <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="flex items-start gap-2">
@@ -447,75 +362,13 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<span className="font-mono">r{item.revision}</span> <span className="font-mono">r{item.revision}</span>
</div> </div>
<div className="mt-3"> <div className="mt-3">
{process ? ( <WorkOrderActivityPanel condition={workOrder} />
<WorkOrderSubtasksPanel condition={process} />
) : (
<WorkbenchNotice tone="neutral" title="未提供过程">这条旧工单没有子任务与阶段数据,不根据总状态推测完成情况。</WorkbenchNotice>
)}
</div> </div>
</article> </article>
); );
} }
function createDefaultSubtask(): PeripheralSubtask { function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
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: ["回填现场处理结果"]
};
}
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 { return {
id: item.work_order_id, id: item.work_order_id,
kind: "work_order", kind: "work_order",
@@ -526,25 +379,16 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
status: item.status === "in_progress" ? "running" : item.status === "awaiting_reply" ? "awaiting_reply" : item.status === "completed" ? "completed" : item.status === "cancelled" ? "cancelled" : "pending", 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", riskLevel: item.priority === "urgent" || item.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(item.updated_at), updatedAt: Date.parse(item.updated_at),
durationMinutes: item.subtasks.reduce((total, subtask) => total + subtask.duration_minutes, 0),
detail: item.summary, detail: item.summary,
source: "外围工单测试", source: "外围工单测试",
dispatcher: item.updated_by, dispatcher: item.updated_by,
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal", priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
dataSource: "backend", dataSource: "backend",
subtasks: item.subtasks.map((subtask) => ({ location: item.location ?? undefined,
id: subtask.id, assignee: item.assignee ?? undefined,
title: subtask.title, feedbackRequirements: item.feedback_requirements ?? [],
summary: subtask.summary, activity: (item.activity ?? []).map((entry) => ({ sequence: entry.sequence, occurredAt: entry.occurred_at,
status: subtask.status, actor: entry.actor, kind: entry.kind, title: entry.title, detail: entry.detail }))
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
}))
}; };
} }
@@ -561,8 +405,6 @@ const WORK_ORDER_STATUS_OPTIONS = [
{ value: "cancelled", label: "已取消" } { value: "cancelled", label: "已取消" }
]; ];
const PRIORITY_OPTIONS = [{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", 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) { function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000; const offset = value.getTimezoneOffset() * 60_000;
@@ -24,6 +24,8 @@ const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrde
updated_at: "2026-09-22T02:00:00Z", updated_at: "2026-09-22T02:00:00Z",
created_by: "user", created_by: "user",
updated_by: "user", updated_by: "user",
feedback_requirements: [],
activity: [],
...overrides ...overrides
}); });
+9 -25
View File
@@ -143,38 +143,22 @@ export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderSt
dispatcher: string; dispatcher: string;
priority: "normal" | "urgent"; priority: "normal" | "urgent";
dataSource?: WorkbenchWorkOrderDataSource; dataSource?: WorkbenchWorkOrderDataSource;
subtasks: ScheduledWorkOrderSubtask[]; location?: string;
assignee?: string;
feedbackRequirements: string[];
activity: ScheduledWorkOrderActivity[];
evidence?: never; evidence?: never;
modelName?: never; modelName?: never;
sessionId?: never; sessionId?: never;
}; };
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending" | "blocked" | "skipped"; export type ScheduledWorkOrderActivity = {
export type ScheduledWorkOrderStage = {
id: string;
sequence: number; sequence: number;
occurredAt: string;
actor: string;
kind: "created" | "updated" | "feedback";
title: string; title: string;
status: ScheduledWorkOrderStageStatus; detail: string;
owner: string;
timeLabel: string;
description: string;
};
export type ScheduledWorkOrderSubtaskStatus = "pending" | "running" | "blocked" | "completed" | "cancelled";
export type ScheduledWorkOrderSubtask = {
id: string;
title: string;
summary: string;
status: ScheduledWorkOrderSubtaskStatus;
scheduledAt: string;
location: string;
assignee: string;
durationMinutes: number;
replyWindowMinutes: number;
stages: ScheduledWorkOrderStage[];
replyRequirements: string[];
}; };
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem; export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
+5 -4
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockHierarchicalWorkOrder } from "./support/mock-hierarchical-work-order"; import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
test.describe("mobile workbench sheet", () => { test.describe("mobile workbench sheet", () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
@@ -138,12 +138,13 @@ test.describe("mobile workbench sheet", () => {
}); });
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => { test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
await mockHierarchicalWorkOrder(page, "reply"); await mockFlatWorkOrder(page, "reply");
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click(); await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" }); const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await sheet.getByRole("button", { name: "展开工况任务" }).click(); await sheet.getByRole("button", { name: "展开工况任务" }).click();
await sheet.getByRole("button", { name: /预约任务/ }).click(); await sheet.getByRole("button", { name: /预约任务/ }).click();
await expect(sheet.getByText("最近工况", { exact: true })).toHaveCount(1);
await sheet.getByRole("button", { name: "收起工况任务" }).click(); await sheet.getByRole("button", { name: "收起工况任务" }).click();
const awaitingReplyWorkOrder = sheet const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]') .locator('button[aria-pressed]')
@@ -152,8 +153,8 @@ test.describe("mobile workbench sheet", () => {
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible(); await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await awaitingReplyWorkOrder.click(); await awaitingReplyWorkOrder.click();
await expect(sheet.getByText("2/3 完成", { exact: true })).toBeVisible(); await expect(sheet.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
await expect(sheet.getByText("确认模型边界已同步更新 · 待复令", { exact: true })).toBeVisible(); await expect(sheet.getByText("活动记录", { exact: true })).toBeVisible();
}); });
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => { test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
+5 -20
View File
@@ -167,23 +167,8 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
work_order_type: "general", work_order_type: "general",
location: "联调测试点东侧", location: "联调测试点东侧",
scenario_code: "APPOINTMENT-TEST-001", scenario_code: "APPOINTMENT-TEST-001",
subtasks: [{ feedback_requirements: ["回填现场处理结果"],
id: "field-service", activity: [],
title: "阀井现场维修",
summary: "按预约窗口完成现场维修并回填结果。",
status: "pending",
scheduled_at: "2026-09-23T10:00:00Z",
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: ["回填现场处理结果"]
}],
origin: "synthetic", origin: "synthetic",
updated_at: "2026-09-23T08:30:00Z" updated_at: "2026-09-23T08:30:00Z"
} }
@@ -282,17 +267,17 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据"); await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端"); await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端"); await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("演示");
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click(); await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检"); await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。"); await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click(); await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible(); await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
await conditionPanel.getByRole("button", { name: /预约任务/ }).click(); await conditionPanel.getByRole("button", { name: /预约任务/ }).click();
await expect(conditionPanel.getByText("最近工况", { exact: true })).toHaveCount(1);
await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible(); await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/^演示工单:/).first()).toBeVisible();
await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click(); await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click();
await expect(conditionPanel.getByText(/阀井现场维修.*待处理/)).toBeVisible(); await expect(conditionPanel.getByText("回执要求", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("回填现场处理结果", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible(); await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).toBeVisible(); await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).toBeVisible();
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
+14 -26
View File
@@ -109,7 +109,7 @@ test.describe("外围工单测试", () => {
await expect(workOrderList).toContainText("待处理"); await expect(workOrderList).toContainText("待处理");
await expect(workOrderList).toContainText("处理中"); await expect(workOrderList).toContainText("处理中");
await expect(workOrderList).toContainText("已完成"); await expect(workOrderList).toContainText("已完成");
await expect(workOrderList.getByText("未提供过程", { exact: true })).toHaveCount(3); await expect(workOrderList.getByText("暂无活动记录;不根据工单状态推测处置过程。", { exact: true })).toHaveCount(3);
expect(requestedPaths).toEqual(expect.arrayContaining([ expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context", "/api/sample/v1/context",
"/api/sample/v1/work-orders" "/api/sample/v1/work-orders"
@@ -254,19 +254,18 @@ test.describe("外围工单测试", () => {
if (path.endsWith("/work-orders") && request.method() === "POST") { if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON(); const body = request.postDataJSON();
writes.push(body); writes.push(body);
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester" }; const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: "预约工单已创建。" }] };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item }); delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
} }
if (path.includes("/work-orders/") && request.method() === "PATCH") { if (path.includes("/work-orders/") && request.method() === "PATCH") {
const body = request.postDataJSON(); const body = request.postDataJSON();
writes.push(body); writes.push(body);
const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z" }; const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z", activity: [...workOrders[0].activity, { sequence: 2, occurred_at: "2026-09-18T01:00:00Z", actor: "tester", kind: "feedback", title: "现场反馈", detail: body.progress_note }] };
if (body.clear_assignee) next.assignee = null; if (body.clear_assignee) next.assignee = null;
if (body.clear_location) next.location = null; if (body.clear_location) next.location = null;
if (body.clear_scenario_code) next.scenario_code = null; if (body.clear_scenario_code) next.scenario_code = null;
if (body.clear_scheduled_at) next.scheduled_at = null; if (body.clear_scheduled_at) next.scheduled_at = null;
if (body.clear_subtasks) next.subtasks = null; for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "progress_note"]) delete next[key];
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "clear_subtasks"]) delete next[key];
workOrders = [next]; workOrders = [next];
return route.fulfill({ json: workOrders[0] }); return route.fulfill({ json: workOrders[0] });
} }
@@ -287,9 +286,7 @@ test.describe("外围工单测试", () => {
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲"); await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧"); await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001"); await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组甲"); await editor.getByRole("textbox", { name: "回执要求(每行一项)" }).fill("回传现场处理结果\n上传照片");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组甲和西侧作业窗口。");
await editor.getByRole("button", { name: "创建预约工单" }).click(); await editor.getByRole("button", { name: "创建预约工单" }).click();
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible(); await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
expect(writes[0]).toMatchObject({ expect(writes[0]).toMatchObject({
@@ -297,11 +294,7 @@ test.describe("外围工单测试", () => {
assignee: "测试班组甲", assignee: "测试班组甲",
location: "联调测试点西侧", location: "联调测试点西侧",
scenario_code: "APPOINTMENT-TEST-001", scenario_code: "APPOINTMENT-TEST-001",
subtasks: expect.arrayContaining([expect.objectContaining({ feedback_requirements: ["回传现场处理结果", "上传照片"]
assignee: "测试班组甲",
location: "联调测试点西侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组甲和西侧作业窗口。" })])
})])
}); });
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click(); await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
@@ -309,12 +302,10 @@ test.describe("外围工单测试", () => {
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)"); await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙"); await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧"); await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组乙"); await editor.getByRole("textbox", { name: "现场反馈(可选)" }).fill("班组乙已确认东侧作业窗口,等待复核。");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组乙和东侧作业窗口,等待真实反馈。");
await editor.getByRole("combobox", { name: "工单状态" }).click(); await editor.getByRole("combobox", { name: "工单状态" }).click();
await page.getByRole("option", { name: "待复令", exact: true }).click(); await page.getByRole("option", { name: "待复令", exact: true }).click();
await editor.getByRole("button", { name: "保存工单与过程" }).click(); await editor.getByRole("button", { name: "保存工单" }).click();
await expect(panel.getByText("合成预约维修(已对齐)", { exact: true })).toBeVisible(); await expect(panel.getByText("合成预约维修(已对齐)", { exact: true })).toBeVisible();
expect(writes[1]).toMatchObject({ expect(writes[1]).toMatchObject({
status: "awaiting_reply", status: "awaiting_reply",
@@ -322,12 +313,8 @@ test.describe("外围工单测试", () => {
location: "联调测试点东侧", location: "联调测试点东侧",
clear_assignee: false, clear_assignee: false,
clear_location: false, clear_location: false,
clear_subtasks: false, progress_note: "班组乙已确认东侧作业窗口,等待复核。",
subtasks: expect.arrayContaining([expect.objectContaining({ feedback_requirements: ["回传现场处理结果", "上传照片"]
assignee: "测试班组乙",
location: "联调测试点东侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组乙和东侧作业窗口,等待真实反馈。" })])
})])
}); });
expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" }); expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" });
@@ -337,8 +324,8 @@ test.describe("外围工单测试", () => {
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click(); await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" }); const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙"); await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
await expect(refreshedEditor.getByRole("textbox", { name: "处置位置" })).toHaveValue("联调测试点东侧"); await expect(refreshedEditor.getByRole("textbox", { name: "位置", exact: true })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "阶段说明", exact: true }).first()).toHaveValue("确认班组乙和东侧作业窗口,等待真实反馈。"); await expect(refreshedEditor.getByRole("textbox", { name: "回执要求(每行一项)" })).toHaveValue("回传现场处理结果\n上传照片");
for (const width of [1280, 375, 320]) { for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 }); await page.setViewportSize({ width, height: 780 });
@@ -730,7 +717,8 @@ function createSourceWorkOrders(projectId: string) {
work_order_type: "general", work_order_type: "general",
location: null, location: null,
scenario_code: null, scenario_code: null,
subtasks: null, feedback_requirements: [],
activity: [],
origin: "synthetic", origin: "synthetic",
created_at: "2026-09-17T00:00:00Z", created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z", updated_at: "2026-09-17T00:00:00Z",
@@ -1,5 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockHierarchicalWorkOrder } from "./support/mock-hierarchical-work-order"; import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -168,7 +168,7 @@ test("switching conditions keeps the detail viewport fully expanded", async ({ p
test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({ test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({
page page
}) => { }) => {
await mockHierarchicalWorkOrder(page, "execution"); await mockFlatWorkOrder(page, "execution");
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page); await openConditionFeed(page);
@@ -200,16 +200,15 @@ test("keeps execution spinners without breathing badges after selecting timeline
await executingWorkOrder.click(); await executingWorkOrder.click();
await expectRunningStatusAnimations(executingWorkOrder); await expectRunningStatusAnimations(executingWorkOrder);
const detail = panel.getByTestId("scheduled-condition-detail-scroll"); const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible(); await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
await expect(detail.getByText("1/3 完成", { exact: true })).toBeVisible(); await expect(detail.getByText("回执要求", { exact: true })).toBeVisible();
await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible(); await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible();
const activeSubtask = detail.locator("details").filter({ hasText: "记录最终开度" }); await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
await expect(activeSubtask).toHaveAttribute("open", ""); await expect(detail.locator("details")).toHaveCount(0);
await expect(activeSubtask.getByText("执行中", { exact: true })).toBeVisible();
}); });
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => { test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await mockHierarchicalWorkOrder(page, "reply"); await mockFlatWorkOrder(page, "reply");
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page); await openConditionFeed(page);
@@ -236,8 +235,8 @@ test("shows completed field work as a static waiting-for-reply state", async ({
); );
await awaitingReplyWorkOrder.click(); await awaitingReplyWorkOrder.click();
await expect(panel.getByText("2/3 完成", { exact: true })).toBeVisible(); await expect(panel.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
await expect(panel.getByText("确认模型边界已同步更新 · 待复令", { exact: true })).toBeVisible(); await expect(panel.getByText("活动记录", { exact: true })).toBeVisible();
await expect( await expect(
panel panel
.getByTestId("scheduled-condition-detail-scroll") .getByTestId("scheduled-condition-detail-scroll")
@@ -3,7 +3,7 @@ import { mockWorkbenchContext } from "./mock-workbench-context";
type WorkOrderPhase = "execution" | "reply"; type WorkOrderPhase = "execution" | "reply";
export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhase) { export async function mockFlatWorkOrder(page: Page, phase: WorkOrderPhase) {
const parentTitle = phase === "execution" const parentTitle = phase === "execution"
? "演示工单:北辰分区阀门开度调整" ? "演示工单:北辰分区阀门开度调整"
: "演示工单:泵站边界复核复令"; : "演示工单:泵站边界复核复令";
@@ -11,7 +11,6 @@ export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhas
const subtaskTitles = phase === "execution" const subtaskTitles = phase === "execution"
? ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"] ? ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"]; : ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"];
const activeIndex = phase === "execution" ? 1 : 2;
const scheduledAt = shanghaiTimestamp(10, 30); const scheduledAt = shanghaiTimestamp(10, 30);
const workOrderId = `mock-${phase}-work-order`; const workOrderId = `mock-${phase}-work-order`;
@@ -40,39 +39,16 @@ export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhas
risk_level: phase === "execution" ? "attention" : "normal", risk_level: phase === "execution" ? "attention" : "normal",
updated_at: scheduledAt, updated_at: scheduledAt,
detail: "浏览器测试使用的后端工单投影。", detail: "浏览器测试使用的后端工单投影。",
recommendation: "按子任务逐项完成并回传。", recommendation: "按回执要求完成并回传。",
duration_minutes: 45, duration_minutes: 45,
source: "生产工单系统", source: "生产工单系统",
dispatcher: "调度中心", dispatcher: "调度中心",
priority: phase === "execution" ? "urgent" : "normal", priority: phase === "execution" ? "urgent" : "normal",
subtasks: subtaskTitles.map((title, index) => { location: phase === "execution" ? "北辰分区边界阀门 BV-07" : "北辰二级泵站 P-02",
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active"; assignee: phase === "execution" ? "北辰抢修二组" : "泵站运行班组",
const stageStatuses = phase === "execution" && position === "active" feedback_requirements: subtaskTitles,
? ["done", "current", "pending"] activity: [{ sequence: 1, occurred_at: scheduledAt, actor: "调度中心", kind: "created",
: phase === "reply" && position === "active" title: "演示工单创建", detail: "预约任务已创建。" }]
? ["done", "done", "current"]
: position === "before"
? ["done", "done", "done"]
: ["pending", "pending", "pending"];
return {
id: `${workOrderId}-subtask-${index + 1}`,
title,
summary: `完成“${title}”并保留阶段记录。`,
status: position === "before" ? "completed" : position === "active" ? "running" : "pending",
scheduled_at: shanghaiTimestamp(10, 30 + index * 15),
location: phase === "execution" ? "北辰分区边界阀门 BV-07" : "北辰二级泵站 P-02",
assignee: phase === "execution" ? "北辰抢修二组" : "泵站运行班组",
duration_minutes: 15,
reply_window_minutes: 20,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: stageStatuses[0], owner: "调度中心", time_label: "10:20", description: "确认范围并派发。" },
{ id: "execution", sequence: 2, title: "执行中", status: stageStatuses[1], owner: "现场班组", time_label: "10:30-10:45", description: "现场执行本项任务。" },
{ id: "reply", sequence: 3, title: "待复令", status: stageStatuses[2], owner: "现场班组", time_label: "10:45-11:05", description: "回传结果并等待复令。" }
],
reply_requirements: [`回传“${title}”的执行结果`]
};
})
}] }]
}); });
} }
+13 -40
View File
@@ -53,23 +53,10 @@ test("history mode opens on the latest day with backend work orders", async ({ p
source: "工单系统", source: "工单系统",
dispatcher: "调度中心", dispatcher: "调度中心",
priority: "normal", priority: "normal",
subtasks: [{ location: "北区阀门 V-17",
id: "wo-history-1-subtask-1", assignee: "北区班组",
title: "阀门状态复核", feedback_requirements: ["回传阀门状态"],
summary: "现场复核阀门状态并回传。", activity: []
status: "running",
scheduled_at: "2026-08-17T09:40:00Z",
location: "北区阀门 V-17",
assignee: "北区班组",
duration_minutes: 30,
reply_window_minutes: 15,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "done", owner: "调度中心", time_label: "17:20", description: "已派发。" },
{ id: "execution", sequence: 2, title: "执行", status: "current", owner: "北区班组", time_label: "17:40-18:10", description: "现场复核中。" },
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "北区班组", time_label: "18:10-18:25", description: "等待结果回传。" }
],
reply_requirements: ["回传阀门状态"]
}]
}] }]
}, undefined, {}, [], { runtimeMode: "history" }); }, undefined, {}, [], { runtimeMode: "history" });
@@ -450,23 +437,11 @@ test("keeps mock conditions while using backend work orders and alerts", async (
source: "生产工单系统", source: "生产工单系统",
dispatcher: "调度中心", dispatcher: "调度中心",
priority: "urgent", priority: "urgent",
subtasks: [{ location: "北区阀门 V-1",
id: "wo-backend-1-subtask-1", assignee: "北区班组",
title: "阀门开度复核", feedback_requirements: ["回传阀门开度"],
summary: "现场复核阀门开度。", activity: [{ sequence: 1, occurred_at: TODAY, actor: "调度中心", kind: "created",
status: "running", title: "工单创建", detail: "现场复核阀门开度并回传。" }]
scheduled_at: TODAY_11_00,
location: "北区阀门 V-1",
assignee: "北区班组",
duration_minutes: 45,
reply_window_minutes: 20,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "done", owner: "调度中心", time_label: "10:50", description: "已派发。" },
{ id: "execution", sequence: 2, title: "执行中", status: "current", owner: "北区班组", time_label: "11:00—11:45", description: "现场执行中。" },
{ id: "reply", sequence: 3, title: "待复令", status: "pending", owner: "北区班组", time_label: "11:45—12:05", description: "等待复令。" }
],
reply_requirements: ["回传阀门开度"]
}]
}] }]
}); });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -481,12 +456,10 @@ test("keeps mock conditions while using backend work orders and alerts", async (
await expect(panel).not.toContainText("工单演示回退"); await expect(panel).not.toContainText("工单演示回退");
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click(); await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
const detail = panel.getByTestId("scheduled-condition-detail-scroll"); const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible(); await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
await expect(detail.getByText(/阀门开度复核/).first()).toBeVisible(); await expect(detail.getByText("回传阀门开度", { exact: true })).toBeVisible();
await expect(detail.getByText("0/1 完成", { exact: true })).toBeVisible(); await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
await expect( await expect(detail.locator("details")).toHaveCount(0);
detail.locator("details").filter({ hasText: "阀门开度复核" }).getByText("执行中", { exact: true })
).toBeVisible();
const alertButton = page.getByRole("button", { const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况" name: "查看异常处置面板,当前 1 条待复核工况"