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