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_execute_commands: z.boolean()
}).strict();
const externalWorkOrderStageSchema = z.object({
id: z.string().min(1),
const externalWorkOrderActivitySchema = z.object({
sequence: z.number().int().positive(),
occurred_at: workbenchDateTimeSchema,
actor: z.string().min(1),
kind: z.enum(["created", "updated", "feedback"]),
title: z.string().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]),
owner: z.string().min(1),
time_label: z.string().min(1),
description: z.string().min(1)
detail: z.string().min(1)
}).strict();
const externalWorkOrderSubtaskSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]),
scheduled_at: workbenchDateTimeSchema,
location: z.string().min(1),
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({
const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
const { subtasks: _legacySubtasks, ...flat } = value as Record<string, unknown>;
return flat;
}
return value;
}, z.object({
work_order_id: z.string().uuid(),
revision: z.number().int().positive(),
title: z.string().min(1),
@@ -190,10 +182,11 @@ const externalWorkOrderSnapshotSchema = z.object({
work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]),
location: 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"),
updated_at: workbenchDateTimeSchema
}).strict();
}).strict());
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
@@ -260,47 +260,11 @@ describe("workbench runtime API client", () => {
source: "生产工单系统",
dispatcher: "调度中心",
priority: "urgent",
subtasks: [{
id: "wo-1-subtask-1",
title: "阀门开度复核",
summary: "现场复核阀门开度。",
status: "completed",
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: ["回传阀门开度"]
}]
feedback_requirements: ["回传阀门开度"],
activity: [{ sequence: 1, occurred_at: "2026-09-17T02:30:00Z", actor: "调度中心",
kind: "created", title: "工单创建", detail: "等待现场复核。" }]
};
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
@@ -315,14 +279,10 @@ describe("workbench runtime API client", () => {
code: "WO-20260917-001",
status: "completed",
source: "生产工单系统",
subtasks: expect.arrayContaining([
expect.objectContaining({
id: "wo-1-subtask-1",
stages: expect.arrayContaining([
expect.objectContaining({ id: "execution", sequence: 2, timeLabel: "11:00—11:45" })
])
})
])
location: "北区阀门 V-1",
assignee: "北区班组",
feedbackRequirements: ["回传阀门开度"],
activity: [expect.objectContaining({ title: "工单创建" })]
})]);
});
@@ -87,50 +87,13 @@ const runtimeLatestAnalysisSchema = z.object({
map_payload: z.unknown().nullable()
});
const runtimeWorkOrderStageSchema = z.object({
id: z.string().trim().min(1),
const runtimeWorkOrderActivitySchema = z.object({
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),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]),
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"]
});
}
detail: z.string().trim().min(1)
});
const runtimeWorkOrderSchema = z.object({
@@ -147,16 +110,11 @@ const runtimeWorkOrderSchema = z.object({
duration_minutes: z.number().int().positive(),
source: 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"]),
subtasks: z.array(runtimeWorkOrderSubtaskSchema).min(1)
}).superRefine((workOrder, context) => {
if (new Set(workOrder.subtasks.map((subtask) => subtask.id)).size !== workOrder.subtasks.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "工单子任务编号不能重复",
path: ["subtasks"]
});
}
feedback_requirements: z.array(z.string().trim().min(1)).default([]),
activity: z.array(runtimeWorkOrderActivitySchema).default([])
});
const runtimeSnapshotSchema = z.object({
@@ -278,26 +236,16 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "backend",
subtasks: workOrder.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({
id: stage.id,
sequence: stage.sequence,
title: stage.title,
status: stage.status,
owner: stage.owner,
timeLabel: stage.time_label,
description: stage.description
})),
replyRequirements: subtask.reply_requirements
location: workOrder.location ?? undefined,
assignee: workOrder.assignee ?? undefined,
feedbackRequirements: workOrder.feedback_requirements,
activity: workOrder.activity.map((entry) => ({
sequence: entry.sequence,
occurredAt: entry.occurred_at,
actor: entry.actor,
kind: entry.kind,
title: entry.title,
detail: entry.detail
}))
}))
};
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
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 { 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", () => {
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 = {
id: "demo-work-order",
kind: "work_order",
@@ -49,33 +49,17 @@ describe("scheduled condition backend boundaries", () => {
dispatcher: "调度岗",
priority: "normal",
dataSource: "mock",
subtasks: [{
id: "field-service",
title: "现场维修",
summary: "按预约窗口处理",
status: "pending",
scheduledAt: "2026-09-23T10:00:00Z",
location: "测试点",
assignee: "维修班组",
durationMinutes: 60,
replyWindowMinutes: 30,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "pending",
owner: "调度岗",
timeLabel: "待安排",
description: "确认预约窗口。"
}],
replyRequirements: ["回填处理结果"]
}]
feedbackRequirements: ["回填处理结果"],
activity: [{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗",
kind: "created", title: "工单创建", detail: "确认预约窗口。" }]
};
render(<WorkOrderSubtasksPanel condition={workOrder} />);
const timeLabel = screen.getByText("待安排", { exact: true });
expect(timeLabel).toHaveClass("tabular-nums");
expect(timeLabel).not.toHaveClass("font-mono");
render(<WorkOrderActivityPanel condition={workOrder} />);
expect(screen.getByText("工单创建")).toBeVisible();
expect(screen.getByText("回填处理结果")).toBeVisible();
expect(screen.queryByText("子任务")).not.toBeInTheDocument();
});
it("shows only persisted evidence for a failed backend condition", () => {
@@ -54,10 +54,6 @@ import {
getStatusActivity,
getStatusTone,
getRiskLabel,
getWorkOrderCurrentSubtask,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
statusLabels
} from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
@@ -125,25 +121,19 @@ export function StatusPill({
condition: ScheduledConditionItem;
className?: string;
}) {
const isWorkOrder = condition.kind === "work_order";
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
return (
<StatusBadge
tone={
isAgentEvent ? "neutral" : isWorkOrder
? getWorkOrderStageTone(currentStage?.status)
: getStatusTone(condition.status)
isAgentEvent ? "neutral" : getStatusTone(condition.status)
}
activity={
isAgentEvent ? "static" : isWorkOrder
? getWorkOrderStageActivity(currentStage?.status)
: getStatusActivity(condition.status)
isAgentEvent ? "static" : getStatusActivity(condition.status)
}
className={className}
>
{isAgentEvent ? "事件调查" : isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
{isAgentEvent ? "事件调查" : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -161,7 +151,7 @@ export function ConditionDetailPanel({
}: ConditionDetailPanelProps) {
const detailKey =
condition.kind === "work_order"
? `${condition.id}-${getWorkOrderCurrentSubtask(condition)?.id ?? "none"}-${getWorkOrderCurrentStage(condition)?.id ?? condition.status}`
? `${condition.id}-${condition.updatedAt}-${condition.status}`
: condition.id;
const content =
condition.kind === "work_order" ? (
@@ -226,7 +216,7 @@ function ConditionRecordDetailPanel({
const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(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)
: null;
@@ -262,12 +252,12 @@ function ConditionRecordDetailPanel({
>
{condition.operationalTask ? (
<>
{externalWorkOrder ? <WorkOrderSubtasksPanel condition={externalWorkOrder} /> : null}
{externalWorkOrder ? <WorkOrderActivityPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot?.subtasks ? (
&& !condition.operationalTask.work_order_snapshot ? (
<DetailSectionCard>
<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>
) : null}
<AgentExecutionPlanPanel
@@ -456,9 +446,6 @@ function WorkOrderDetailPanel({
condition: ScheduledWorkOrderItem;
dataSource: WorkbenchWorkOrderDataSource;
}) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const currentStage = getWorkOrderCurrentStage(condition);
return (
<DetailScroll>
<DetailShell>
@@ -471,17 +458,15 @@ function WorkOrderDetailPanel({
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend"
? "工单的子任务、阶段、责任人和复令要求均来自后端持久化记录。"
? "工单状态、负责人、回执要求与活动记录均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
/>
<WorkOrderSubtasksPanel condition={condition} />
<WorkOrderActivityPanel condition={condition} />
<DecisionActionStrip
icon={CalendarClock}
title={currentSubtask && currentStage
? `${currentSubtask.title} · ${currentStage.title}`
: "工单流转"}
value={condition.recommendation ?? "按子任务及其阶段跟踪现场处置闭环。"}
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
title="工单跟踪"
value={condition.recommendation ?? "按工单活动记录跟踪现场处置。"}
actionLabel={condition.status === "awaiting_reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
/>
@@ -1286,93 +1271,42 @@ function TaskExecutionStepRow({
);
}
export function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const completedCount = condition.subtasks.filter((subtask) =>
subtask.status === "completed" || subtask.status === "cancelled"
).length;
export function WorkOrderActivityPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
return (
<DetailSectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={ListChecks} title="子任务" />
<StatusBadge tone={completedCount === condition.subtasks.length ? "success" : "info"}>
{completedCount}/{condition.subtasks.length} 完成
</StatusBadge>
<SectionTitle icon={ClipboardList} title="预约工单" />
<StatusBadge tone={getStatusTone(condition.status)}>{statusLabels[condition.status]}</StatusBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
<ReportMetaTile label="来源" value={condition.source} />
<ReportMetaTile label="派发人" value={condition.dispatcher} />
<ReportMetaTile label="负责人" value={condition.assignee ?? "待分配"} />
<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>
{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>
))}
</ul>
) : null}
</div>
</Disclosure>
);
})}
</div>
</ol>
) : <p className="mt-2 text-xs text-slate-500">暂无活动记录;不根据工单状态推测处置过程。</p>}
</section>
</DetailSectionCard>
);
}
@@ -1396,53 +1330,20 @@ function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): Sche
: "pending",
riskLevel: snapshot.priority === "urgent" || snapshot.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(snapshot.updated_at),
durationMinutes: snapshot.subtasks?.reduce((total, item) => total + item.duration_minutes, 0),
detail: snapshot.summary,
source: "外围工单系统",
dispatcher: "外围系统同步",
priority: snapshot.priority === "urgent" || snapshot.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
subtasks: (snapshot.subtasks ?? []).map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({
id: stage.id,
sequence: stage.sequence,
title: stage.title,
status: stage.status,
owner: stage.owner,
timeLabel: stage.time_label,
description: stage.description
})),
replyRequirements: subtask.reply_requirements
}))
location: snapshot.location ?? undefined,
assignee: snapshot.assignee ?? undefined,
feedbackRequirements: snapshot.feedback_requirements,
activity: snapshot.activity.map((entry) => ({ sequence: entry.sequence,
occurredAt: entry.occurred_at, actor: entry.actor, kind: entry.kind,
title: entry.title, detail: entry.detail }))
};
}
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[] }) {
return (
<DetailSectionCard>
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import {
groupScheduledConditions,
getStatusActivity,
getStatusTone,
getWorkOrderStageActivity,
getWorkOrderStageTone,
getWorkOrderCurrentSubtask
sortConditionsNewestFirst
} from "./scheduled-condition-feed-utils";
import type { ScheduledWorkOrderItem } from "../types";
import type { ScheduledConditionItem, ScheduledWorkOrderItem } from "../types";
describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => {
@@ -29,74 +28,73 @@ describe("scheduled condition status presentation", () => {
expect(getStatusActivity("awaiting_reply")).toBe("static");
});
it("maps work-order stage states to the shared semantic language", () => {
expect({
done: getWorkOrderStageTone("done"),
current: getWorkOrderStageTone("current"),
pending: getWorkOrderStageTone("pending"),
blocked: getWorkOrderStageTone("blocked"),
skipped: getWorkOrderStageTone("skipped")
}).toEqual({
done: "success",
current: "info",
pending: "neutral",
blocked: "danger",
skipped: "neutral"
it("sorts mixed task representations by their full scheduled timestamp", () => {
const conditions = [
createCondition("older-condition", "condition", "2026-09-17T10:00:00+08:00"),
createCondition("newer-work-order", "work_order", "2026-09-18T09:00:00+08:00"),
createCondition("newest-condition", "condition", "2026-09-18T11:00:00+08:00")
];
expect(sortConditionsNewestFirst(conditions).map((condition) => condition.id)).toEqual([
"newest-condition",
"newer-work-order",
"older-condition"
]);
const grouped = groupScheduledConditions(conditions);
expect(grouped.recentConditions.map((condition) => condition.id)).toEqual([
"newest-condition",
"older-condition"
]);
expect(grouped.futureWorkOrders.map((condition) => condition.id)).toEqual([
"newer-work-order"
]);
});
expect(getWorkOrderStageActivity("current")).toBe("loading");
});
it("prefers an executing subtask over a pending subtask at its dispatch stage", () => {
const workOrder = {
id: "work-order-1",
function createCondition(
id: string,
kind: ScheduledConditionItem["kind"],
scheduledAt: string
): ScheduledConditionItem {
if (kind === "work_order") {
return {
...createWorkOrder(id),
scheduledAt
};
}
return {
id,
kind: "condition",
taskId: "scada-diagnosis",
sessionId: `session-${id}`,
title: id,
summary: id,
status: "completed",
riskLevel: "normal",
scheduledAt,
updatedAt: Date.parse(scheduledAt)
};
}
function createWorkOrder(id: string): ScheduledWorkOrderItem {
return {
id,
kind: "work_order",
code: "WO-1",
code: id,
scheduledAt: "2026-09-17T10:00:00+08:00",
title: "多子任务工单",
summary: "测试当前子任务选择。",
status: "running",
title: id,
summary: id,
status: "pending",
riskLevel: "normal",
updatedAt: 1,
durationMinutes: 30,
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(
id: string,
status: "pending" | "running",
currentStage: "dispatch" | "execution"
) {
return {
id,
title: id,
summary: id,
status,
scheduledAt: "2026-09-17T10:00:00+08:00",
location: "演示点位",
assignee: "演示班组",
durationMinutes: 15,
replyWindowMinutes: 10,
stages: [
{
id: currentStage,
sequence: 1,
title: currentStage,
status: "current" as const,
owner: "演示班组",
timeLabel: "10:00",
description: "当前阶段"
}
],
replyRequirements: []
location: "测试点",
assignee: "测试班组",
feedbackRequirements: [],
activity: []
};
}
@@ -8,9 +8,7 @@ import {
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
import type {
ScheduledConditionItem,
ScheduledConditionItemStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
ScheduledConditionItemStatus
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
@@ -55,22 +53,6 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
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[] = [
"running",
"awaiting_reply",
@@ -96,14 +78,14 @@ export type ConditionStatusFilterOption = {
count: number;
};
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
const recentConditions = conditions
.filter((condition) => condition.kind === "condition")
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
export function sortConditionsNewestFirst(conditions: ScheduledConditionItem[]) {
return [...conditions].sort((a, b) => getConditionTime(b) - getConditionTime(a));
}
const futureWorkOrders = conditions
.filter((condition) => condition.kind === "work_order")
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
const newestFirst = sortConditionsNewestFirst(conditions);
const recentConditions = newestFirst.filter((condition) => condition.kind === "condition");
const futureWorkOrders = newestFirst.filter((condition) => condition.kind === "work_order");
return { recentConditions, futureWorkOrders };
}
@@ -178,38 +160,6 @@ export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
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) {
return statusIcons[status];
}
@@ -46,14 +46,11 @@ import {
createStatusFilterOptions,
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusActivity,
getStatusIcon,
getStatusTone,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
groupScheduledConditions,
sortConditionsNewestFirst,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
type ConditionTaskFilterOption,
@@ -171,10 +168,6 @@ export function ScheduledConditionFeed({
() => groupScheduledConditions(categoryConditions),
[categoryConditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(
() => createTaskFilterOptions(categoryConditions),
[categoryConditions]
@@ -203,8 +196,10 @@ export function ScheduledConditionFeed({
[filteredConditions]
);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
() => taskCategory === "appointment"
? sortConditionsNewestFirst([...recentConditions, ...futureWorkOrders])
: [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions, taskCategory]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
@@ -214,8 +209,7 @@ export function ScheduledConditionFeed({
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
futureWorkOrders[0] ??
timelineConditions[0] ??
null;
const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId;
@@ -294,7 +288,7 @@ export function ScheduledConditionFeed({
function handleToggleExpanded() {
updateExpanded(!expanded);
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);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null);
updateSelectedConditionId(
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
}
function handleToggleSchedule() {
@@ -562,6 +558,7 @@ export function ScheduledConditionFeed({
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
taskCategory={taskCategory}
loading={activeLoading}
emptyTitle={
backendError && taskCategory !== "scheduled"
@@ -575,6 +572,7 @@ export function ScheduledConditionFeed({
}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
timelineConditions={timelineConditions}
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
@@ -648,9 +646,11 @@ function formatLocalDateTimeInput(value: Date) {
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
taskCategory: WorkbenchTaskCategory;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
timelineConditions: ScheduledConditionItem[];
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
@@ -670,9 +670,11 @@ type ConditionTimelinePanelProps = {
function ConditionTimelinePanel({
expanded,
presentation,
taskCategory,
loading,
recentConditions,
futureWorkOrders,
timelineConditions,
taskFilter,
statusFilter,
taskFilterOptions,
@@ -689,6 +691,7 @@ function ConditionTimelinePanel({
onSelectCondition
}: ConditionTimelinePanelProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const mainConditions = taskCategory === "appointment" ? timelineConditions : recentConditions;
return (
<section
@@ -718,7 +721,10 @@ function ConditionTimelinePanel({
onResetFilters={onResetFilters}
/>
) : null}
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
{taskCategory !== "appointment"
&& (expanded || presentation === "mobile-sheet")
&& !loading
&& futureWorkOrders.length > 0 ? (
<section className="mb-2">
<ConditionGroupHeader
icon={ClipboardList}
@@ -736,16 +742,16 @@ function ConditionTimelinePanel({
</section>
) : null}
<ConditionGroupHeader
icon={History}
icon={taskCategory === "appointment" ? ClipboardList : History}
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">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
) : recentConditions.length > 0 ? (
) : mainConditions.length > 0 ? (
<ConditionRows
conditions={recentConditions}
conditions={mainConditions}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
@@ -1078,18 +1084,8 @@ function ConditionTimelineRow({
onSelect
}: ConditionTimelineRowProps) {
const Icon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.status : undefined;
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);
const tone = getStatusTone(condition.status);
const activity = getStatusActivity(condition.status);
return (
<button
@@ -16,7 +16,7 @@ export const WORKBENCH_TASK_CATEGORIES = [
{
id: "appointment",
label: "预约任务",
sourceLabel: "后端 / 演示"
sourceLabel: "后端"
}
] as const satisfies readonly {
id: WorkbenchTaskCategory;
@@ -29,44 +29,13 @@ describe("createOperationalScheduledConditions", () => {
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 executingWorkOrder = conditions.find(
(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("执行中");
expect(conditions.every((condition) => condition.kind === "condition")).toBe(true);
});
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 awaitingReplyWorkOrder = conditions.find(
(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("待复令");
expect(conditions.some((condition) => condition.kind === "work_order")).toBe(false);
});
});
@@ -5,9 +5,7 @@ import type {
ScheduledConditionReport,
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId,
ScheduledWorkOrderSubtask,
ScheduledWorkOrderStatus
ScheduledConditionTaskId
} from "../types";
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";
type ConditionTaskId = ScheduledConditionTaskId;
type WorkOrderPhase = "dispatch" | "execution" | "reply";
type ConditionTaskDefinition = {
id: ConditionTaskId;
title: string;
@@ -37,32 +33,6 @@ type TaskSignal = {
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 = {
action: 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[] {
const currentMinutesOfDay = getMinutesOfDay(now);
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
: [runningCondition, ...historicalConditions.filter((condition) => condition.id !== runningCondition.id)];
const futureWorkOrders = createUpcomingWorkOrders(now, currentMinutesOfDay);
return [...operationalConditions, ...futureWorkOrders].sort(
return operationalConditions.sort(
(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(
task: ConditionTaskDefinition,
scheduledAt: Date,
@@ -1146,10 +804,6 @@ function formatClock(date: Date) {
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) {
const yyyy = date.getFullYear().toString();
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
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 {
return {
@@ -69,7 +73,8 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
work_order_type: "general",
location: "测试点",
scenario_code: null,
subtasks: null,
feedback_requirements: [],
activity: [],
origin: "synthetic",
updated_at: "2026-09-23T09:00:00Z"
}
@@ -99,3 +104,38 @@ describe("operational task condition mapper", () => {
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
} from "../api/operational-task-client";
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchSyncState } from "./use-workbench-sync";
@@ -30,6 +31,31 @@ export type CreateInvestigationInput = {
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(
getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState
@@ -99,15 +125,11 @@ export function useOperationalTasks(
if (events.length === 0) return;
const controller = new AbortController();
const lastSequence = events.at(-1)!.event_seq;
const taskIds = new Set<string>();
let notificationsChanged = false;
for (const event of events) {
if (event.topic === "operational.notification.changed") notificationsChanged = true;
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue;
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);
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
if (deletedTaskIds.size > 0) {
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
setTasks(tasksRef.current);
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
}
void Promise.all([
notificationsChanged
@@ -47,27 +47,10 @@ const mockWorkOrder: ScheduledWorkOrderItem = {
source: "演示",
dispatcher: "调度中心",
priority: "normal",
subtasks: [{
id: "mock-subtask-1",
title: "现场复核",
summary: "复核设备状态并回传。",
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: ["回传设备状态"]
}]
feedbackRequirements: ["回传设备状态"],
activity: []
};
describe("workbench runtime data", () => {
@@ -109,13 +92,13 @@ describe("workbench runtime data", () => {
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 resolved = resolveWorkbenchRuntimeData([mockCondition, demoWorkOrder], null, {
operationalDate: SEPTEMBER_17
});
expect(resolved.scheduledConditions).toEqual([mockCondition, demoWorkOrder]);
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.workOrderDataSource).toBe("none");
});
@@ -276,7 +259,7 @@ describe("workbench runtime data", () => {
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], {
availability: "ready",
source: "none",
@@ -294,7 +277,7 @@ describe("workbench runtime data", () => {
workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder]);
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend");
});
@@ -379,7 +362,7 @@ describe("workbench runtime data", () => {
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder, backendWorkOrder]);
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend");
});
});
@@ -80,10 +80,6 @@ export function resolveWorkbenchRuntimeData(
(condition) => condition.kind === "condition"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const demoWorkOrders = mockConditions.filter(
(condition) => condition.kind === "work_order"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
);
@@ -107,7 +103,7 @@ export function resolveWorkbenchRuntimeData(
);
return {
scheduledConditions: [...fallbackConditions, ...demoWorkOrders, ...resolvedWorkOrders],
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions),
@@ -26,27 +26,11 @@ const workOrder: ScheduledConditionItem = {
source: "调度方案",
dispatcher: "调度",
priority: "urgent",
subtasks: [{
id: "work-1-subtask-1",
title: "调整阀门开度",
summary: "现场调整并记录开度。",
status: "running",
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: ["回传阀门开度"]
}]
feedbackRequirements: ["回传阀门开度"],
activity: [{ sequence: 1, occurredAt: "2026-08-19T10:00:00.000Z", actor: "班组",
kind: "updated", title: "现场反馈", detail: "现场调整阀门。" }]
};
describe("operational timeline model", () => {
@@ -60,8 +44,8 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("does not invent actual execution times for backend work orders", () => {
const events = createOperationalEvents([{ ...workOrder, dataSource: "backend" }]);
it("does not invent actual execution times without activity", () => {
const events = createOperationalEvents([{ ...workOrder, dataSource: "backend", activity: [] }]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ lane: "plan", actualTime: null });
@@ -195,13 +195,10 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const subtask = getRepresentativeWorkOrderSubtask(item);
const planEvent: OperationalEvent = {
id: `plan-${item.id}`,
title: item.title,
description: subtask
? `${subtask.title} · ${subtask.location} · ${subtask.assignee}`
: item.summary,
description: [item.summary, item.location, item.assignee].filter(Boolean).join(" · "),
lane: "plan",
status: "planned",
importance: item.priority === "urgent" ? "important" : "normal",
@@ -215,32 +212,16 @@ function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[]
mapTargets: getWorkOrderMapTargets(item)
};
if (item.dataSource === "backend") {
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 = {
return [planEvent, ...item.activity.map((entry): OperationalEvent => ({
...planEvent,
id: `actual-${item.id}`,
id: `activity-${item.id}-${entry.sequence}`,
title: entry.title,
lane: "actual",
status: "executed",
actualTime,
cursorTime: actualTime,
description:
item.status === "running"
? `${subtask?.assignee ?? "执行班组"}正在执行,位置:${subtask?.location ?? "待确认"}`
: `${subtask?.assignee ?? "执行班组"}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
actualTime: entry.occurredAt,
cursorTime: entry.occurredAt,
description: entry.detail
}))];
}
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
@@ -254,32 +235,10 @@ function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[]
}
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
const targets = item.subtasks.flatMap((subtask): FeatureTarget[] => {
if (subtask.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(subtask.location) }];
}
if (subtask.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(subtask.location) }];
}
const location = item.location ?? "";
if (location.includes("阀")) return [{ sourceId: "valves", featureId: extractAssetId(location) }];
if (location.includes("流量计")) return [{ sourceId: "scada", featureId: extractAssetId(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) {
@@ -37,6 +37,8 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
updated_at: "2026-09-18T00:00:00Z",
created_by: "user",
updated_by: "user",
feedback_requirements: [],
activity: [],
...overrides
});
@@ -49,7 +51,8 @@ const createWorkOrderInput: Omit<PeripheralCreateWorkOrder, "request_id"> = {
scheduled_at: null,
work_order_type: "general",
location: null,
scenario_code: null
scenario_code: null,
feedback_requirements: []
};
describe("PeripheralSampleClient", () => {
@@ -182,7 +185,7 @@ describe("PeripheralSampleClient", () => {
clear_location: false,
clear_scenario_code: false,
clear_scheduled_at: false,
clear_subtasks: false
feedback_requirements: []
})).resolves.toEqual(order);
await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted);
@@ -325,6 +325,8 @@ export interface components {
CreateWorkOrder: {
/** Assignee */
assignee?: string | null;
/** Feedback Requirements */
feedback_requirements?: string[];
/** Location */
location?: string | null;
/**
@@ -348,8 +350,6 @@ export interface components {
* @enum {string}
*/
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary: string;
/** Title */
@@ -914,17 +914,16 @@ export interface components {
* @default false
*/
clear_scheduled_at: boolean;
/**
* Clear Subtasks
* @default false
*/
clear_subtasks: boolean;
/** Expected Revision */
expected_revision: number;
/** Feedback Requirements */
feedback_requirements?: string[] | null;
/** Location */
location?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Progress Note */
progress_note?: string | null;
/**
* Request Id
* Format: uuid
@@ -936,8 +935,6 @@ export interface components {
scheduled_at?: string | null;
/** Status */
status?: ("draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled") | null;
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary?: string | null;
/** Title */
@@ -945,6 +942,8 @@ export interface components {
};
/** WorkOrder */
WorkOrder: {
/** Activity */
activity?: components["schemas"]["WorkOrderActivity"][];
/** Assignee */
assignee: string | null;
/**
@@ -954,6 +953,8 @@ export interface components {
created_at: string;
/** Created By */
created_by: string;
/** Feedback Requirements */
feedback_requirements?: string[];
/** Location */
location?: string | null;
/**
@@ -982,8 +983,6 @@ export interface components {
* @enum {string}
*/
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary: string;
/** Title */
@@ -1007,6 +1006,27 @@ export interface components {
*/
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: {
/** Items */
@@ -1014,57 +1034,6 @@ export interface components {
/** Next Cursor */
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;
parameters: never;
@@ -12,12 +12,11 @@ import {
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { 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 type { ScheduledWorkOrderItem } from "../types";
import type {
@@ -26,9 +25,6 @@ import type {
PeripheralWorkOrder
} from "./client";
type PeripheralSubtask = NonNullable<PeripheralWorkOrder["subtasks"]>[number];
type PeripheralStage = PeripheralSubtask["stages"][number];
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
@@ -72,7 +68,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
return (
<WorkbenchSection
title="工单接口测试"
description="验证创建、编辑、过程状态、反馈回填与后端同步。"
description="验证单层预约工单、现场反馈与后端同步。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
@@ -119,7 +115,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [location, setLocation] = useState("");
const [scenarioCode, setScenarioCode] = useState("");
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [subtasks, setSubtasks] = useState<PeripheralSubtask[] | null>(null);
const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
@@ -138,13 +135,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setLocation("");
setScenarioCode("");
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setSubtasks(null);
setFeedbackRequirements("");
setProgressNote("");
};
const create = () => {
clear();
setEditorOpen(true);
setSubtasks([createDefaultSubtask()]);
};
const edit = (item: PeripheralWorkOrder) => {
@@ -158,7 +155,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setLocation(item.location ?? "");
setScenarioCode(item.scenario_code ?? "");
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) => {
@@ -186,7 +184,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
work_order_type: eventKind,
location: eventLocation.trim(),
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
subtasks: null
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
@@ -194,15 +192,14 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
await triggerAgent(workOrder);
});
const processValid = Boolean(subtasks?.length && subtasks.every(isValidSubtask));
const canSave = canWrite && !busy && title.trim() && summary.trim() && processValid;
const canSave = canWrite && !busy && title.trim() && summary.trim();
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-slate-800">测试工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">过程字段将随工单写入,并按原结构同步到预约任务。</p>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张预约工单对应一条任务;修改和现场反馈按时间记录。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
@@ -256,11 +253,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<ProcessEditor subtasks={subtasks} disabled={!canWrite || Boolean(busy)} onChange={setSubtasks} />
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
const normalizedSubtasks = subtasks!.map(normalizeSubtask);
const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
if (editing) {
await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision,
@@ -276,8 +274,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
subtasks: normalizedSubtasks,
clear_subtasks: false
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
});
} else {
await client.createWorkOrder({
@@ -290,13 +288,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
work_order_type: "general",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
subtasks: normalizedSubtasks
feedback_requirements: requirements
});
}
clear();
})}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单与过程" : "创建预约工单"}
{editing ? "保存工单" : "创建预约工单"}
</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button>
</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 }: {
item: PeripheralWorkOrder;
index: number;
@@ -421,7 +336,7 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
onEdit: () => void;
onDelete: () => void;
}) {
const process = toScheduledWorkOrder(item);
const workOrder = toScheduledWorkOrder(item);
return (
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<div className="flex items-start gap-2">
@@ -447,75 +362,13 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
{process ? (
<WorkOrderSubtasksPanel condition={process} />
) : (
<WorkbenchNotice tone="neutral" title="未提供过程">这条旧工单没有子任务与阶段数据,不根据总状态推测完成情况。</WorkbenchNotice>
)}
<WorkOrderActivityPanel condition={workOrder} />
</div>
</article>
);
}
function createDefaultSubtask(): PeripheralSubtask {
const scheduledAt = new Date(Date.now() + 60 * 60_000).toISOString();
return {
id: `subtask-${crypto.randomUUID()}`,
title: "现场处置",
summary: "按预约要求完成现场处置并回填结果。",
status: "pending",
scheduled_at: scheduledAt,
location: "待填写位置",
assignee: "待分配班组",
duration_minutes: 60,
reply_window_minutes: 30,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "pending", owner: "调度岗", time_label: "待安排", description: "确认班组、位置和预约窗口。" },
{ id: "execution", sequence: 2, title: "执行", status: "pending", owner: "执行班组", time_label: "待安排", description: "按工单要求处置并记录现场结果。" },
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "调度岗", time_label: "待安排", description: "核对回执要求并确认闭环。" }
],
reply_requirements: ["回填现场处理结果"]
};
}
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;
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
return {
id: item.work_order_id,
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",
riskLevel: item.priority === "urgent" || item.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(item.updated_at),
durationMinutes: item.subtasks.reduce((total, subtask) => total + subtask.duration_minutes, 0),
detail: item.summary,
source: "外围工单测试",
dispatcher: item.updated_by,
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
subtasks: item.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({ id: stage.id, sequence: stage.sequence, title: stage.title, status: stage.status, owner: stage.owner, timeLabel: stage.time_label, description: stage.description })),
replyRequirements: subtask.reply_requirements
}))
location: item.location ?? undefined,
assignee: item.assignee ?? undefined,
feedbackRequirements: item.feedback_requirements ?? [],
activity: (item.activity ?? []).map((entry) => ({ sequence: entry.sequence, occurredAt: entry.occurred_at,
actor: entry.actor, kind: entry.kind, title: entry.title, detail: entry.detail }))
};
}
@@ -561,8 +405,6 @@ const WORK_ORDER_STATUS_OPTIONS = [
{ value: "cancelled", label: "已取消" }
];
const PRIORITY_OPTIONS = [{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }];
const SUBTASK_STATUS_OPTIONS = [{ value: "pending", label: "待处理" }, { value: "running", label: "处理中" }, { value: "blocked", label: "受阻" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }];
const STAGE_STATUS_OPTIONS = [{ value: "pending", label: "待开始" }, { value: "current", label: "当前阶段" }, { value: "done", label: "已完成" }, { value: "blocked", label: "受阻" }, { value: "skipped", label: "已跳过" }];
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
@@ -24,6 +24,8 @@ const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrde
updated_at: "2026-09-22T02:00:00Z",
created_by: "user",
updated_by: "user",
feedback_requirements: [],
activity: [],
...overrides
});
+9 -25
View File
@@ -143,38 +143,22 @@ export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderSt
dispatcher: string;
priority: "normal" | "urgent";
dataSource?: WorkbenchWorkOrderDataSource;
subtasks: ScheduledWorkOrderSubtask[];
location?: string;
assignee?: string;
feedbackRequirements: string[];
activity: ScheduledWorkOrderActivity[];
evidence?: never;
modelName?: never;
sessionId?: never;
};
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending" | "blocked" | "skipped";
export type ScheduledWorkOrderStage = {
id: string;
export type ScheduledWorkOrderActivity = {
sequence: number;
occurredAt: string;
actor: string;
kind: "created" | "updated" | "feedback";
title: string;
status: ScheduledWorkOrderStageStatus;
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[];
detail: string;
};
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
+5 -4
View File
@@ -1,5 +1,5 @@
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.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 }) => {
await mockHierarchicalWorkOrder(page, "reply");
await mockFlatWorkOrder(page, "reply");
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
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();
const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]')
@@ -152,8 +153,8 @@ test.describe("mobile workbench sheet", () => {
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
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 }) => {
+5 -20
View File
@@ -167,23 +167,8 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
work_order_type: "general",
location: "联调测试点东侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: [{
id: "field-service",
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: ["回填现场处理结果"]
}],
feedback_requirements: ["回填现场处理结果"],
activity: [],
origin: "synthetic",
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 conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
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(/^演示工单:/).first()).toBeVisible();
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(/位置:联调测试点东侧/)).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.getByText("未提供过程", { exact: true })).toHaveCount(3);
await expect(workOrderList.getByText("暂无活动记录;不根据工单状态推测处置过程。", { exact: true })).toHaveCount(3);
expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context",
"/api/sample/v1/work-orders"
@@ -254,19 +254,18 @@ test.describe("外围工单测试", () => {
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
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 });
}
if (path.includes("/work-orders/") && request.method() === "PATCH") {
const body = request.postDataJSON();
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_location) next.location = null;
if (body.clear_scenario_code) next.scenario_code = 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", "clear_subtasks"]) delete next[key];
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "progress_note"]) delete next[key];
workOrders = [next];
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: "场景编号" }).fill("APPOINTMENT-TEST-001");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组甲和西侧作业窗口。");
await editor.getByRole("textbox", { name: "回执要求(每行一项)" }).fill("回传现场处理结果\n上传照片");
await editor.getByRole("button", { name: "创建预约工单" }).click();
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
expect(writes[0]).toMatchObject({
@@ -297,11 +294,7 @@ test.describe("外围工单测试", () => {
assignee: "测试班组甲",
location: "联调测试点西侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组甲",
location: "联调测试点西侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组甲和西侧作业窗口。" })])
})])
feedback_requirements: ["回传现场处理结果", "上传照片"]
});
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
@@ -309,12 +302,10 @@ test.describe("外围工单测试", () => {
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: "责任班组" }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组乙和东侧作业窗口,等待真实反馈。");
await editor.getByRole("textbox", { name: "现场反馈(可选)" }).fill("班组乙已确认东侧作业窗口,等待复核。");
await editor.getByRole("combobox", { name: "工单状态" }).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();
expect(writes[1]).toMatchObject({
status: "awaiting_reply",
@@ -322,12 +313,8 @@ test.describe("外围工单测试", () => {
location: "联调测试点东侧",
clear_assignee: false,
clear_location: false,
clear_subtasks: false,
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组乙",
location: "联调测试点东侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组乙和东侧作业窗口,等待真实反馈。" })])
})])
progress_note: "班组乙已确认东侧作业窗口,等待复核。",
feedback_requirements: ["回传现场处理结果", "上传照片"]
});
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();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
await expect(refreshedEditor.getByRole("textbox", { name: "处置位置" })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "阶段说明", exact: true }).first()).toHaveValue("确认班组乙和东侧作业窗口,等待真实反馈。");
await expect(refreshedEditor.getByRole("textbox", { name: "位置", exact: true })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "回执要求(每行一项)" })).toHaveValue("回传现场处理结果\n上传照片");
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 });
@@ -730,7 +717,8 @@ function createSourceWorkOrders(projectId: string) {
work_order_type: "general",
location: null,
scenario_code: null,
subtasks: null,
feedback_requirements: [],
activity: [],
origin: "synthetic",
created_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 { 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 }) => {
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 ({
page
}) => {
await mockHierarchicalWorkOrder(page, "execution");
await mockFlatWorkOrder(page, "execution");
await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page);
@@ -200,16 +200,15 @@ test("keeps execution spinners without breathing badges after selecting timeline
await executingWorkOrder.click();
await expectRunningStatusAnimations(executingWorkOrder);
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible();
await expect(detail.getByText("1/3 完成", { exact: true })).toBeVisible();
await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
await expect(detail.getByText("回执要求", { exact: true })).toBeVisible();
await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible();
const activeSubtask = detail.locator("details").filter({ hasText: "记录最终开度" });
await expect(activeSubtask).toHaveAttribute("open", "");
await expect(activeSubtask.getByText("执行中", { exact: true })).toBeVisible();
await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
await expect(detail.locator("details")).toHaveCount(0);
});
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 openConditionFeed(page);
@@ -236,8 +235,8 @@ test("shows completed field work as a static waiting-for-reply state", async ({
);
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(
panel
.getByTestId("scheduled-condition-detail-scroll")
@@ -3,7 +3,7 @@ import { mockWorkbenchContext } from "./mock-workbench-context";
type WorkOrderPhase = "execution" | "reply";
export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhase) {
export async function mockFlatWorkOrder(page: Page, phase: WorkOrderPhase) {
const parentTitle = phase === "execution"
? "演示工单:北辰分区阀门开度调整"
: "演示工单:泵站边界复核复令";
@@ -11,7 +11,6 @@ export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhas
const subtaskTitles = phase === "execution"
? ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"];
const activeIndex = phase === "execution" ? 1 : 2;
const scheduledAt = shanghaiTimestamp(10, 30);
const workOrderId = `mock-${phase}-work-order`;
@@ -40,39 +39,16 @@ export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhas
risk_level: phase === "execution" ? "attention" : "normal",
updated_at: scheduledAt,
detail: "浏览器测试使用的后端工单投影。",
recommendation: "按子任务逐项完成并回传。",
recommendation: "按回执要求完成并回传。",
duration_minutes: 45,
source: "生产工单系统",
dispatcher: "调度中心",
priority: phase === "execution" ? "urgent" : "normal",
subtasks: subtaskTitles.map((title, index) => {
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
const stageStatuses = phase === "execution" && position === "active"
? ["done", "current", "pending"]
: phase === "reply" && position === "active"
? ["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}”的执行结果`]
};
})
feedback_requirements: subtaskTitles,
activity: [{ sequence: 1, occurred_at: scheduledAt, actor: "调度中心", kind: "created",
title: "演示工单创建", detail: "预约任务已创建。" }]
}]
});
}
+9 -36
View File
@@ -53,23 +53,10 @@ test("history mode opens on the latest day with backend work orders", async ({ p
source: "工单系统",
dispatcher: "调度中心",
priority: "normal",
subtasks: [{
id: "wo-history-1-subtask-1",
title: "阀门状态复核",
summary: "现场复核阀门状态并回传。",
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: ["回传阀门状态"]
}]
feedback_requirements: ["回传阀门状态"],
activity: []
}]
}, undefined, {}, [], { runtimeMode: "history" });
@@ -450,23 +437,11 @@ test("keeps mock conditions while using backend work orders and alerts", async (
source: "生产工单系统",
dispatcher: "调度中心",
priority: "urgent",
subtasks: [{
id: "wo-backend-1-subtask-1",
title: "阀门开度复核",
summary: "现场复核阀门开度。",
status: "running",
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: ["回传阀门开度"]
}]
feedback_requirements: ["回传阀门开度"],
activity: [{ sequence: 1, occurred_at: TODAY, actor: "调度中心", kind: "created",
title: "工单创建", detail: "现场复核阀门开度并回传。" }]
}]
});
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 panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible();
await expect(detail.getByText(/阀门开度复核/).first()).toBeVisible();
await expect(detail.getByText("0/1 完成", { exact: true })).toBeVisible();
await expect(
detail.locator("details").filter({ hasText: "阀门开度复核" }).getByText("执行中", { exact: true })
).toBeVisible();
await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
await expect(detail.getByText("回传阀门开度", { exact: true })).toBeVisible();
await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
await expect(detail.locator("details")).toHaveCount(0);
const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况"