From ade88b4fbf3a5aaf06af94226f0c921f6780682f Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 27 Jul 2026 14:58:29 +0800 Subject: [PATCH] fix: distinguish awaiting work order replies --- .../scheduled-condition-detail-panel.tsx | 32 ++++++----- .../scheduled-condition-feed-utils.ts | 37 +++++++----- .../components/scheduled-condition-feed.tsx | 2 +- .../data/scheduled-conditions.test.ts | 16 ++++++ .../workbench/data/scheduled-conditions.ts | 57 +++++++++++++------ src/features/workbench/types.ts | 12 ++-- .../utils/scheduled-condition-prompts.ts | 5 +- tests/browser/mobile-workbench-sheet.e2e.ts | 13 +++++ .../scheduled-condition-acrylic.e2e.ts | 18 ++++++ 9 files changed, 143 insertions(+), 49 deletions(-) diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 073aab5..ae12099 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -86,6 +86,7 @@ type ExecutionStep = { title: string; description: string; status: ExecutionStepStatus; + statusLabel?: string; meta?: ExecutionStepMeta[]; }; @@ -274,7 +275,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem icon={CalendarClock} title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"} value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"} - actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"} + actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"} actionDisabled actionTitle="工单流程由调度和现场班组流转" /> @@ -1063,7 +1064,7 @@ function TaskExecutionStepRow({ {step.title} - {getTaskExecutionStepStatusLabel(step.status)} + {step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)} {step.meta?.length ? ( @@ -1085,23 +1086,28 @@ function TaskExecutionStepRow({ function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) { const duration = formatDuration(condition.durationMinutes, condition.status); const currentStage = getWorkOrderCurrentStage(condition); - const steps = condition.stages.map((stage) => ({ - id: stage.id, - title: stage.title, - status: stage.status, - description: stage.description, - meta: [ - { label: "责任人", value: stage.owner }, - { label: "时间", value: stage.timeLabel } - ] - })); + const steps = condition.stages.map((stage) => { + const awaitingReply = stage.id === "reply" && stage.status === "current"; + + return { + id: stage.id, + title: stage.title, + status: awaitingReply ? ("confirmation" as const) : stage.status, + statusLabel: awaitingReply ? "待复令" : undefined, + description: stage.description, + meta: [ + { label: "责任人", value: stage.owner }, + { label: "时间", value: stage.timeLabel } + ] + }; + }); return ( = { +export const statusLabels: Record = { completed: "完成", running: "执行中", + awaiting_reply: "待复令", warning: "关注", error: "异常", pending: "预约" }; -export const statusClassNames: Record = { +export const statusClassNames: Record = { completed: "border-green-100 bg-green-50 text-green-700", running: "border-blue-100 bg-blue-50 text-blue-700", + awaiting_reply: "border-orange-100 bg-orange-50 text-orange-700", warning: "border-orange-100 bg-orange-50 text-orange-700", error: "border-red-100 bg-red-50 text-red-700", pending: "border-slate-200 bg-slate-100 text-slate-600" }; -const statusIcons: Record = { +const statusIcons: Record = { completed: CheckCircle2, running: Loader2, + awaiting_reply: Clock3, warning: AlertTriangle, error: XCircle, pending: Clock3 }; -const statusIconClassNames: Record = { +const statusIconClassNames: Record = { completed: "bg-green-50 text-green-700", running: "bg-blue-50 text-blue-700", + awaiting_reply: "bg-orange-50 text-orange-700", warning: "bg-orange-50 text-orange-700", error: "bg-red-50 text-red-700", pending: "bg-slate-100 text-slate-600" }; -const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"]; +const conditionStatusOrder: ScheduledConditionItemStatus[] = [ + "running", + "awaiting_reply", + "warning", + "error", + "completed", + "pending" +]; export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string; -export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus; +export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus; export type ConditionTaskFilterOption = { value: ConditionTaskFilterValue; @@ -58,7 +69,7 @@ export type ConditionTaskFilterOption = { }; export type ConditionStatusFilterOption = { - value: ScheduledConditionStatus; + value: ScheduledConditionItemStatus; label: string; count: number; }; @@ -88,7 +99,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C } export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] { - const statusCounts = new Map(); + const statusCounts = new Map(); conditions.forEach((condition) => { statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1); @@ -121,7 +132,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin return `${startTime} - ${endTime}`; } -export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) { +export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) { if (status === "running") { return "执行中"; } @@ -160,16 +171,16 @@ export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage } if (stageId === "reply") { - return "border-green-100 bg-green-50 text-green-700"; + return "border-orange-100 bg-orange-50 text-orange-700"; } return "border-indigo-100 bg-indigo-50 text-indigo-700"; } -export function getStatusIcon(status: ScheduledConditionStatus) { +export function getStatusIcon(status: ScheduledConditionItemStatus) { return statusIcons[status]; } -export function getStatusIconClassName(status: ScheduledConditionStatus) { +export function getStatusIconClassName(status: ScheduledConditionItemStatus) { return statusIconClassNames[status]; } diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index 89591c6..edf9107 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -478,7 +478,7 @@ function ConditionTimelinePanel({ onResetFilters={onResetFilters} /> ) : null} - {expanded && !loading && futureWorkOrders.length > 0 ? ( + {(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
{ expect(currentCondition?.status).toBe("running"); expect(currentCondition?.durationMinutes).toBe(5); }); + + it("moves completed field work into a waiting-for-reply state", () => { + const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00")); + const awaitingReplyWorkOrder = conditions.find( + (condition) => + condition.kind === "work_order" && + condition.stages.some((stage) => stage.id === "reply" && stage.status === "current") + ); + + expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply"); + expect( + awaitingReplyWorkOrder?.kind === "work_order" + ? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title + : undefined + ).toBe("待复令"); + }); }); diff --git a/src/features/workbench/data/scheduled-conditions.ts b/src/features/workbench/data/scheduled-conditions.ts index 4cb9314..2a8d3df 100644 --- a/src/features/workbench/data/scheduled-conditions.ts +++ b/src/features/workbench/data/scheduled-conditions.ts @@ -5,7 +5,8 @@ import type { ScheduledConditionReport, ScheduledConditionRiskLevel, ScheduledConditionStatus, - ScheduledConditionTaskId + ScheduledConditionTaskId, + ScheduledWorkOrderStatus } from "../types"; export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000; @@ -16,6 +17,7 @@ const MODEL_NAME = "DrainFlow Realtime Simulator"; const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis"; type ConditionTaskId = ScheduledConditionTaskId; +type WorkOrderPhase = "dispatch" | "execution" | "reply"; type ConditionTaskDefinition = { id: ConditionTaskId; @@ -332,6 +334,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched .map((workOrder) => { const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute); const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes; + const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay); return { id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`, @@ -340,7 +343,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched scheduledAt: formatLocalIsoWithOffset(scheduledAtDate), title: workOrder.title, summary: workOrder.summary, - status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const), + status: getWorkOrderStatus(phase), riskLevel: workOrder.riskLevel, updatedAt: now.getTime(), detail: workOrder.detail, @@ -352,7 +355,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched assignee: workOrder.assignee, priority: workOrder.priority, replyWindowMinutes: workOrder.replyWindowMinutes, - stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay), + stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase), replyRequirements: currentMinutesOfDay > replyDeadlineMinute ? [] @@ -385,11 +388,38 @@ function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMin 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 createWorkOrderStages( workOrder: ManualWorkOrderDefinition, now: Date, scheduledAtDate: Date, - currentMinutesOfDay: number + phase: WorkOrderPhase ) { const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes)); const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes); @@ -397,13 +427,11 @@ function createWorkOrderStages( now, workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes ); - const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes; - return [ { id: "dispatch" as const, title: "派发", - status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const), + status: phase === "dispatch" ? ("current" as const) : ("done" as const), owner: workOrder.dispatcher, timeLabel: formatClock(dispatchAt), description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。` @@ -412,22 +440,19 @@ function createWorkOrderStages( id: "execution" as const, title: "执行", status: - currentMinutesOfDay >= executionEndMinute - ? ("done" as const) - : currentMinutesOfDay >= workOrder.startMinute + phase === "dispatch" + ? ("pending" as const) + : phase === "execution" ? ("current" as const) - : ("pending" as const), + : ("done" as const), owner: workOrder.assignee, timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes), description: workOrder.summary }, { id: "reply" as const, - title: "复令", - status: - currentMinutesOfDay >= executionEndMinute - ? ("current" as const) - : ("pending" as const), + title: "待复令", + status: phase === "reply" ? ("current" as const) : ("pending" as const), owner: `${workOrder.assignee} → 调度中心`, timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes), description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。` diff --git a/src/features/workbench/types.ts b/src/features/workbench/types.ts index e461b73..ca48f1c 100644 --- a/src/features/workbench/types.ts +++ b/src/features/workbench/types.ts @@ -32,6 +32,10 @@ export type WorkbenchUser = { export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error"; +export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply"; + +export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus; + export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical"; export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation"; @@ -71,12 +75,12 @@ export type ScheduledConditionReport = { }[]; }; -type ScheduledConditionBase = { +type ScheduledConditionBase = { id: string; scheduledAt: string; title: string; summary: string; - status: ScheduledConditionStatus; + status: TStatus; riskLevel: ScheduledConditionRiskLevel; updatedAt: number; detail?: string; @@ -84,7 +88,7 @@ type ScheduledConditionBase = { durationMinutes?: number; }; -export type ScheduledConditionRecord = ScheduledConditionBase & { +export type ScheduledConditionRecord = ScheduledConditionBase & { kind: "condition"; taskId: ScheduledConditionTaskId; sessionId: string; @@ -95,7 +99,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & { report?: ScheduledConditionReport; }; -export type ScheduledWorkOrderItem = ScheduledConditionBase & { +export type ScheduledWorkOrderItem = ScheduledConditionBase & { kind: "work_order"; code: string; source: string; diff --git a/src/features/workbench/utils/scheduled-condition-prompts.ts b/src/features/workbench/utils/scheduled-condition-prompts.ts index a7fa2f8..51801db 100644 --- a/src/features/workbench/utils/scheduled-condition-prompts.ts +++ b/src/features/workbench/utils/scheduled-condition-prompts.ts @@ -1,11 +1,12 @@ import { getRunningWorkflowDefinition } from "../data/running-workflows"; -import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types"; +import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types"; const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-"; -const statusLabels: Record = { +const statusLabels: Record = { completed: "完成", running: "执行中", + awaiting_reply: "待复令", warning: "关注", error: "异常", pending: "预约" diff --git a/tests/browser/mobile-workbench-sheet.e2e.ts b/tests/browser/mobile-workbench-sheet.e2e.ts index 72effe9..e148f70 100644 --- a/tests/browser/mobile-workbench-sheet.e2e.ts +++ b/tests/browser/mobile-workbench-sheet.e2e.ts @@ -128,6 +128,19 @@ test.describe("mobile workbench sheet", () => { await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible(); }); + test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "打开工况任务" }).click(); + const sheet = page.getByRole("region", { name: "工况任务抽屉" }); + const awaitingReplyWorkOrder = sheet + .locator('button[aria-pressed]') + .filter({ hasText: "系统工单:泵站边界复核复令" }); + + await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible(); + await awaitingReplyWorkOrder.click(); + await expect(sheet.getByText("当前阶段:待复令", { exact: true })).toBeVisible(); + }); + test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "打开 Agent 面板" }).click(); diff --git a/tests/browser/scheduled-condition-acrylic.e2e.ts b/tests/browser/scheduled-condition-acrylic.e2e.ts index 8eb6a09..75f4674 100644 --- a/tests/browser/scheduled-condition-acrylic.e2e.ts +++ b/tests/browser/scheduled-condition-acrylic.e2e.ts @@ -82,6 +82,24 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn await expect(agentPanel).toBeVisible(); }); +test("shows completed field work as a static waiting-for-reply state", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const panel = page.getByRole("region", { name: "工况任务", exact: true }); + await panel.getByRole("button", { name: "展开工况任务" }).click(); + const awaitingReplyWorkOrder = panel + .getByTestId("scheduled-condition-timeline") + .locator('button[aria-pressed]') + .filter({ hasText: "系统工单:泵站边界复核复令" }); + + await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible(); + await expect(awaitingReplyWorkOrder.locator(".animate-spin")).toHaveCount(0); + await expect(awaitingReplyWorkOrder.locator(".bg-orange-50")).toBeVisible(); + + await awaitingReplyWorkOrder.click(); + await expect(panel.getByText("当前阶段:待复令", { exact: true })).toBeVisible(); +}); + async function expectAcrylicComposition(panel: Locator) { const composition = await panel.evaluate((element) => { const rootStyle = getComputedStyle(element);