feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -146,7 +146,11 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
|
||||
};
|
||||
|
||||
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
|
||||
return runningWorkflowDefinitions[condition.taskId];
|
||||
const workflow = runningWorkflowDefinitions[condition.taskId as ScheduledConditionTaskId];
|
||||
if (!workflow) {
|
||||
throw new Error("Backend conditions do not use inferred workflow definitions");
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
export function getRunningWorkflowState(
|
||||
|
||||
@@ -34,14 +34,18 @@ describe("createOperationalScheduledConditions", () => {
|
||||
const executingWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some(
|
||||
(stage) => stage.id === "execution" && stage.status === "current"
|
||||
condition.subtasks.some((subtask) =>
|
||||
subtask.stages.some(
|
||||
(stage) => stage.id === "execution" && stage.status === "current"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
executingWorkOrder?.kind === "work_order"
|
||||
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
|
||||
? executingWorkOrder.subtasks
|
||||
.flatMap((subtask) => subtask.stages)
|
||||
.find((stage) => stage.id === "execution" && stage.status === "current")?.title
|
||||
: undefined
|
||||
).toBe("执行中");
|
||||
});
|
||||
@@ -51,13 +55,17 @@ describe("createOperationalScheduledConditions", () => {
|
||||
const awaitingReplyWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
|
||||
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.stages.find((stage) => stage.id === "reply")?.title
|
||||
? awaitingReplyWorkOrder.subtasks
|
||||
.flatMap((subtask) => subtask.stages)
|
||||
.find((stage) => stage.id === "reply" && stage.status === "current")?.title
|
||||
: undefined
|
||||
).toBe("待复令");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ScheduledConditionRiskLevel,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionTaskId,
|
||||
ScheduledWorkOrderSubtask,
|
||||
ScheduledWorkOrderStatus
|
||||
} from "../types";
|
||||
|
||||
@@ -13,7 +14,7 @@ export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
const HISTORY_WINDOW_MINUTES = 6 * 60;
|
||||
const GENERATED_SESSION_ID_PREFIX = "scheduled-condition-";
|
||||
const MODEL_NAME = "DrainFlow Realtime Simulator";
|
||||
const MODEL_NAME = "SupplyFlow 前端演示模拟器";
|
||||
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
|
||||
|
||||
type ConditionTaskId = ScheduledConditionTaskId;
|
||||
@@ -103,9 +104,9 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
|
||||
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
|
||||
{
|
||||
id: "mock-incident-valve-dispatch",
|
||||
title: "系统工单:华山路阀门边界确认",
|
||||
title: "演示工单:华山路阀门边界确认",
|
||||
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
|
||||
detail: "该工单由异常事件分析流程生成,用于演示人工确认后提交系统工单并通知管网要素操作。",
|
||||
detail: "该条目是前端演示回退,用于展示人工确认界面,不代表真实提交或岗位通知。",
|
||||
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
|
||||
source: "异常事件分析",
|
||||
location: "华山路沿线阀门组 VG-11",
|
||||
@@ -121,7 +122,7 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: "mock-valve-operation-execution",
|
||||
title: "系统工单:北辰分区阀门开度调整",
|
||||
title: "演示工单:北辰分区阀门开度调整",
|
||||
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
|
||||
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
|
||||
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
|
||||
@@ -139,7 +140,7 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: "mock-pump-boundary-reply",
|
||||
title: "系统工单:泵站边界复核复令",
|
||||
title: "演示工单:泵站边界复核复令",
|
||||
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
|
||||
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
|
||||
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
|
||||
@@ -160,9 +161,9 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
|
||||
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
|
||||
{
|
||||
id: "huashan-valve-well-service",
|
||||
title: "人工工单:华山路阀井现场处置",
|
||||
title: "演示人工工单:华山路阀井现场处置",
|
||||
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
|
||||
detail: "该工单由调度员人工创建,来源为现场巡检反馈,不属于周期工况任务。",
|
||||
detail: "该条目模拟调度员根据现场巡检反馈创建人工工单,不代表真实提交或派发。",
|
||||
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
|
||||
source: "现场巡检反馈",
|
||||
location: "华山路阀井",
|
||||
@@ -178,9 +179,9 @@ const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "renmin-road-meter-change",
|
||||
title: "人工工单:人民路流量计更换",
|
||||
title: "演示人工工单:人民路流量计更换",
|
||||
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
|
||||
detail: "该工单由计量维护计划人工创建,执行前需确认旁通和作业窗口。",
|
||||
detail: "该条目模拟计量维护计划中的人工工单,执行前需确认旁通和作业窗口。",
|
||||
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
|
||||
source: "计量维护计划",
|
||||
location: "人民路 DN300 支线",
|
||||
@@ -196,9 +197,9 @@ const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "beichen-fire-hydrant-repair",
|
||||
title: "人工工单:北辰消防栓维修",
|
||||
title: "演示人工工单:北辰消防栓维修",
|
||||
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
|
||||
detail: "该工单来自客服缺陷转派,由调度员人工排入当日维修窗口。",
|
||||
detail: "该条目模拟客服缺陷转派,由调度员排入当日维修窗口,不代表真实派发。",
|
||||
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
|
||||
source: "客服缺陷转派",
|
||||
location: "北辰片区消防栓",
|
||||
@@ -333,7 +334,6 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
)
|
||||
.map((workOrder) => {
|
||||
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
|
||||
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
|
||||
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
|
||||
|
||||
return {
|
||||
@@ -350,16 +350,9 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
recommendation: workOrder.recommendation,
|
||||
durationMinutes: workOrder.durationMinutes,
|
||||
source: workOrder.source,
|
||||
location: workOrder.location,
|
||||
dispatcher: workOrder.dispatcher,
|
||||
assignee: workOrder.assignee,
|
||||
priority: workOrder.priority,
|
||||
replyWindowMinutes: workOrder.replyWindowMinutes,
|
||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
|
||||
replyRequirements:
|
||||
currentMinutesOfDay > replyDeadlineMinute
|
||||
? []
|
||||
: workOrder.replyRequirements
|
||||
subtasks: createWorkOrderSubtasks(workOrder, now, scheduledAtDate, phase)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt));
|
||||
@@ -415,44 +408,112 @@ function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
|
||||
return "awaiting_reply";
|
||||
}
|
||||
|
||||
function createWorkOrderStages(
|
||||
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 = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
|
||||
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
|
||||
const replyEnd = setMinutesOfDay(
|
||||
now,
|
||||
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
|
||||
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" as const,
|
||||
id: "dispatch",
|
||||
sequence: 1,
|
||||
title: "派发",
|
||||
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
|
||||
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" as const,
|
||||
id: "execution",
|
||||
sequence: 2,
|
||||
title: "执行中",
|
||||
status:
|
||||
phase === "dispatch"
|
||||
allDone
|
||||
? ("done" as const)
|
||||
: allPending || phase === "dispatch"
|
||||
? ("pending" as const)
|
||||
: phase === "execution"
|
||||
? ("current" as const)
|
||||
: ("done" as const),
|
||||
owner: workOrder.assignee,
|
||||
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
|
||||
timeLabel: formatClockRange(subtaskScheduledAt, subtaskDurationMinutes),
|
||||
description: workOrder.summary
|
||||
},
|
||||
{
|
||||
id: "reply" as const,
|
||||
id: "reply",
|
||||
sequence: 3,
|
||||
title: "待复令",
|
||||
status: phase === "reply" ? ("current" as const) : ("pending" as const),
|
||||
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)} 前完成复令。`
|
||||
@@ -795,7 +856,7 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
|
||||
},
|
||||
{
|
||||
action: "末端压力观察",
|
||||
target: "华山路末端压力点 P-117",
|
||||
target: "华山路末端压力点 DEMO-P001",
|
||||
command: "将该点加入 15 分钟高频复核队列",
|
||||
verification: "若压力低于 0.18MPa 持续两轮,转人工调度确认"
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@ import type { MapAnnotation } from "../types";
|
||||
export const mapAnnotations: MapAnnotation[] = [
|
||||
{
|
||||
id: "burst",
|
||||
label: "爆管位置",
|
||||
label: "演示爆管位置",
|
||||
coordinate: [121.5215, 30.9084],
|
||||
kind: "burst"
|
||||
},
|
||||
{
|
||||
id: "dn600",
|
||||
label: "DN600",
|
||||
label: "演示 DN600",
|
||||
coordinate: [121.512, 30.9102],
|
||||
kind: "pipe-label"
|
||||
},
|
||||
{
|
||||
id: "east-gate",
|
||||
label: "东直门小区",
|
||||
label: "演示片区",
|
||||
coordinate: [121.545, 30.951],
|
||||
kind: "district"
|
||||
},
|
||||
{
|
||||
id: "impact",
|
||||
label: "影响范围 约 1.82 km²",
|
||||
label: "演示影响范围 约 1.82 km²",
|
||||
coordinate: [121.567, 30.896],
|
||||
kind: "tooltip"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export const impactAreaPolygon = {
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: "impact-area",
|
||||
label: "影响范围",
|
||||
label: "演示影响范围",
|
||||
area: "1.82 km²"
|
||||
},
|
||||
geometry: {
|
||||
|
||||
Reference in New Issue
Block a user