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