feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
+8 -2
View File
@@ -33,7 +33,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
},
{
id: "old",
@@ -43,7 +46,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
}
]);
});
+38 -4
View File
@@ -2,6 +2,7 @@ import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
export type AgentEventTriggerContext = {
schema_version: "agent-trigger@1";
@@ -51,9 +52,12 @@ export type AgentChatSession = {
created?: boolean;
event_duplicate?: boolean;
trigger_id?: string;
purpose?: AgentSessionPurpose;
task_id?: string;
};
export type AgentChatSessionSummary = {
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
id: string;
title: string;
createdAt: number;
@@ -62,6 +66,8 @@ export type AgentChatSessionSummary = {
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
purpose: AgentSessionPurpose;
taskId?: string;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
@@ -71,6 +77,7 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
};
export type AgentSessionStreamDataEventType =
| "execution_plan"
| "token"
| "progress"
| "todo_update"
@@ -90,6 +97,7 @@ export type AgentSessionStreamDataEventType =
export type AgentSessionStreamEvent =
| {
type: "state";
executionPlans?: unknown[];
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -103,7 +111,8 @@ export type AgentSessionStreamEvent =
export type AgentApiClient = {
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
@@ -184,11 +193,23 @@ export function createAgentApiClient(
});
},
async listSessions() {
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
async listSessions(listOptions) {
const params = new URLSearchParams();
if (listOptions?.purpose) params.set("purpose", listOptions.purpose);
if (listOptions?.taskId) params.set("task_id", listOptions.taskId);
const payload = await requestJson<{ sessions?: unknown[] }>(
`/sessions${params.size ? `?${params.toString()}` : ""}`
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async createTaskCollaborationSession(taskId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
{ method: "POST" }
);
},
async getFrontendActionRegistry() {
return requestJson<unknown>("/frontend-action-registry");
},
@@ -432,6 +453,10 @@ function readRunStatus(value: unknown): AgentRunStatus | undefined {
: undefined;
}
function readSessionPurpose(value: unknown): AgentSessionPurpose {
return value === "task_execution" || value === "task_collaboration" ? value : "ordinary_chat";
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
@@ -450,7 +475,14 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
status: typeof value.status === "string" ? value.status : undefined,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
runStatus: readRunStatus(value.run_status),
purpose: readSessionPurpose(value.purpose),
taskId: typeof value.task_id === "string" ? value.task_id : undefined,
trigger: isRecord(value.trigger) && value.trigger.workflow === "water_quality_complaint" &&
typeof value.trigger.subject_id === "string" && typeof value.trigger.occurred_at === "string" &&
Number.isFinite(Date.parse(value.trigger.occurred_at))
? { subjectId: value.trigger.subject_id, workflow: "water_quality_complaint", occurredAt: value.trigger.occurred_at }
: undefined
};
}
@@ -530,6 +562,7 @@ function emitSessionSseEvent(
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
onEvent({
type: "state",
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
@@ -554,6 +587,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
+4 -2
View File
@@ -35,13 +35,15 @@ describe("Agent API SWR helpers", () => {
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
updatedAt: 2,
purpose: "ordinary_chat" as const
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
await expect(fetchAgentSessions(client)).resolves.toEqual(sessions);
expect(client.listSessions).toHaveBeenCalledWith({ purpose: "ordinary_chat" });
});
});
+3 -1
View File
@@ -32,5 +32,7 @@ export async function fetchAgentBootstrap(client: AgentApiClient): Promise<Agent
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
const sessions = await client.listSessions({ purpose: "ordinary_chat" });
// Keep the product boundary even while an older Agent service is rolling forward.
return sessions.filter((session) => session.purpose === "ordinary_chat");
}
@@ -108,6 +108,7 @@ type AgentCommandPanelProps = {
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
@@ -162,6 +163,7 @@ export function AgentCommandPanel({
sessionHistory = [],
sessionHistoryLoading = false,
activeSessionId,
qaOnly = false,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
@@ -561,6 +563,7 @@ export function AgentCommandPanel({
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
qaOnly={qaOnly}
/>
) : (
<motion.div
@@ -581,7 +584,7 @@ export function AgentCommandPanel({
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
@@ -653,11 +656,7 @@ export function AgentCommandPanel({
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ApprovalModeSelect
value={approvalMode}
disabled={streaming}
onValueChange={onApprovalModeChange}
/>
{!qaOnly ? <ApprovalModeSelect value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-slate-500">任务协作问答</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
@@ -1238,7 +1237,8 @@ function BackendMessageList({
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech
onStopSpeech,
qaOnly
}: {
messages: AgentChatMessage[];
streaming: boolean;
@@ -1256,6 +1256,7 @@ function BackendMessageList({
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
qaOnly: boolean;
}) {
return (
<motion.div
@@ -1286,7 +1287,7 @@ function BackendMessageList({
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
{message.role === "assistant" && !qaOnly ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
@@ -1319,7 +1320,7 @@ function BackendMessageList({
) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">正在生成...</span>
) : null}
{message.role === "assistant" ? (
{message.role === "assistant" && !qaOnly ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { executionPlanStages, parseExecutionPlan, readExecutionPlanHistory } from "./execution-plan";
const snapshot = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1, updated_at: "2026-09-23T01:00:00.000Z",
title: "调查", reason: "开始",
nodes: [{ id: "a", title: '水质"] --> injected["<script>', status: "waiting", owner: "human", detail: "等待采样", evidence: [] }], edges: []
};
describe("execution plan projection", () => {
it("groups independent checks before their shared downstream decision", () => {
const plan = parseExecutionPlan({ ...snapshot,
nodes: ["a", "b", "c", "d"].map((id) => ({ ...snapshot.nodes[0], id })),
edges: [{ from: "a", to: "b", label: "" }, { from: "a", to: "c", label: "" }, { from: "b", to: "d", label: "" }, { from: "c", to: "d", label: "" }]
}, "s1")!;
expect(executionPlanStages(plan).map((stage) => stage.map((node) => node.id))).toEqual([["a"], ["b", "c"], ["d"]]);
});
it("rejects other sessions and dangling dependencies", () => {
expect(parseExecutionPlan(snapshot, "s2")).toBeNull();
expect(parseExecutionPlan({ ...snapshot, edges: [{ from: "a", to: "b", label: "" }] }, "s1")).toBeNull();
});
it("restores and deduplicates UI and active-run snapshots without completing waiting work", () => {
const history = readExecutionPlanHistory([{ parts: [{ type: "data-execution_plan", data: snapshot }] }, { executionPlans: [snapshot] }], "s1");
expect(history).toHaveLength(1);
expect(history[0].nodes[0].status).toBe("waiting");
});
it("rejects cyclic dependencies rather than guessing their order", () => {
expect(parseExecutionPlan({ ...snapshot, nodes: [snapshot.nodes[0], { ...snapshot.nodes[0], id: "b" }], edges: [{ from: "a", to: "b", label: "" }, { from: "b", to: "a", label: "" }] }, "s1")).toBeNull();
});
});
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const schema = z.object({
schema_version: z.literal("execution-plan@1"),
session_id: z.string().min(1),
revision: z.number().int().positive(),
updated_at: z.string().datetime(),
title: z.string().min(1).max(120),
reason: z.string().min(1).max(1000),
nodes: z.array(z.object({
id, title: z.string().min(1).max(120),
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
owner: z.enum(["agent", "human"]),
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12)
})).min(1).max(40),
edges: z.array(z.object({ from: id, to: id, label: z.string().max(120) })).max(80)
}).refine((plan) => {
const ids = new Set(plan.nodes.map((node) => node.id));
return ids.size === plan.nodes.length && plan.edges.every((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
});
export type AgentExecutionPlan = z.infer<typeof schema>;
export function parseExecutionPlan(value: unknown, sessionId: string): AgentExecutionPlan | null {
const parsed = schema.safeParse(value);
return parsed.success && parsed.data.session_id === sessionId && executionPlanStages(parsed.data).length ? parsed.data : null;
}
export function readExecutionPlanHistory(messages: unknown[], sessionId: string) {
const plans = messages.flatMap((message) => {
const value = (message as { executionPlans?: unknown } | null)?.executionPlans;
const parts = (message as { parts?: unknown } | null)?.parts;
const data = Array.isArray(parts) ? parts.filter((part) => part?.type === "data-execution_plan").map((part) => part.data) : [];
return [...(Array.isArray(value) ? value : []), ...data];
}).map((value) => parseExecutionPlan(value, sessionId)).filter((plan): plan is AgentExecutionPlan => plan !== null);
return [...new Map(plans.map((plan) => [plan.revision, plan])).values()].sort((a, b) => a.revision - b.revision);
}
export const executionStatusLabels = {
pending: "待执行", running: "执行中", waiting: "等待资料/人工", completed: "已完成",
failed: "失败", skipped: "不适用", cancelled: "已取消"
};
/** Same-stage nodes have no dependency on one another; this describes eligibility, not a scheduler. */
export function executionPlanStages(plan: AgentExecutionPlan): AgentExecutionPlan["nodes"][] {
const remaining = new Set(plan.nodes.map((node) => node.id));
const stages: AgentExecutionPlan["nodes"][] = [];
while (remaining.size) {
const nodes = plan.nodes.filter((node) => remaining.has(node.id) &&
plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
if (!nodes.length) return []; // Malformed cyclic history is never converted into a guessed sequence.
stages.push(nodes);
nodes.forEach((node) => remaining.delete(node.id));
}
return stages;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { appendExecutionReportText, readExecutionReport, updateExecutionReportProgress } from "./execution-report";
describe("execution report", () => {
it("uses assistant output and public tool records without exposing reasoning or inventing completion", () => {
const report = readExecutionReport([
{ role: "user", content: "不应成为调查结论" },
{ id: "a", role: "assistant", parts: [
{ type: "text", text: "疑似二次供水问题,待现场核验。" },
{ type: "data-progress", data: { id: "private", phase: "planning", title: "推理内容", status: "running" } },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "核对记录", status: "running" } }
] }
]);
expect(report.responses).toEqual([{ id: "a", text: "疑似二次供水问题,待现场核验。" }]);
expect(report.activities).toHaveLength(1);
expect(report.activities[0].status).toBe("running");
expect(updateExecutionReportProgress(report, { id: "tool", phase: "tool", title: "查询失败", status: "error" }).activities[0].status).toBe("error");
});
it("joins resumed chunks to the active snapshot despite differing transport IDs", () => {
const report = readExecutionReport([{ id: "legacy", role: "assistant", content: "上游指标正常。" }]);
const next = appendExecutionReportText(report, { message_id: "ui-assistant", content: "等待现场采样。" });
expect(next.responses).toEqual([{ id: "ui-assistant", text: "上游指标正常。等待现场采样。" }]);
});
it("keeps a new empty assistant turn separate from the previous report", () => {
const report = readExecutionReport([{ id: "old", role: "assistant", content: "前序结论" }, { id: "current", role: "assistant", content: "" }]);
const next = appendExecutionReportText(report, { message_id: "current-ui", content: "最新进展" });
expect(next.responses.map((response) => response.text)).toEqual(["前序结论", "最新进展"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
export type AgentExecutionReport = {
responses: { id: string; text: string }[];
activities: { id: string; title: string; detail?: string; status: "running" | "completed" | "error" }[];
};
const record = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
export function updateExecutionReportProgress(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const progress = record(value);
// The report contains public execution facts, not the model's reasoning stream.
if (!progress || !["tool", "start", "execution_plan"].includes(String(progress.phase)) ||
typeof progress.id !== "string" || typeof progress.title !== "string" ||
!["running", "completed", "error"].includes(String(progress.status))) return report;
const activity = {
id: progress.id, title: progress.title,
detail: typeof progress.detail === "string" ? progress.detail : undefined,
status: progress.status as "running" | "completed" | "error"
};
const existing = report.activities.findIndex((item) => item.id === activity.id);
return { ...report, activities: existing < 0 ? [...report.activities, activity].slice(-8) :
report.activities.map((item, index) => index === existing ? activity : item) };
}
export function readExecutionReport(messages: unknown[]): AgentExecutionReport {
let report: AgentExecutionReport = { responses: [], activities: [] };
messages.forEach((value, index) => {
const message = record(value);
if (!message || message.role !== "assistant") return;
const parts = Array.isArray(message.parts) ? message.parts.map(record).filter((part) => part !== null) : [];
const text = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("") ||
(typeof message.content === "string" ? message.content : "");
report.responses.push({ id: typeof message.id === "string" ? message.id : `response-${index}`, text });
for (const progress of Array.isArray(message.progress) ? message.progress : []) report = updateExecutionReportProgress(report, progress);
for (const part of parts) if (part.type === "data-progress") report = updateExecutionReportProgress(report, part.data);
});
return { ...report, responses: report.responses.slice(-3) };
}
export function appendExecutionReportText(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const data = record(value);
if (!data || typeof data.content !== "string") return report;
const id = typeof data.message_id === "string" ? data.message_id : "current-response";
const matching = report.responses.findIndex((response) => response.id === id);
// Active-run snapshots and UI stream chunks currently use different assistant IDs.
const existing = matching < 0 ? report.responses.length - 1 : matching;
return { ...report, responses: existing < 0 ? [...report.responses, { id, text: data.content }].slice(-3) :
report.responses.map((response, index) => index === existing ? { ...response, id, text: response.text + data.content } : response) };
}
+3
View File
@@ -1,4 +1,6 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
@@ -11,6 +13,7 @@ export type {
AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
"pointer-events-auto absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
@@ -117,7 +117,10 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
onClick={onClick}
size="sm"
variant="outline"
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
className={cn(
"pointer-events-auto mt-2 border-blue-100 bg-blue-50 text-blue-700",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
{label}
</Button>
+3 -3
View File
@@ -15,9 +15,9 @@ export function MapToaster() {
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-auto",
toast: "group pointer-events-none",
closeButton:
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
}
}}
/>
@@ -90,7 +90,7 @@ export function MapSourceStatusNotice({
tone: getSourceStatusTone(status),
title: getSourceStatusTitle(sourceName, status),
message: message ?? getSourceStatusMessage(status),
duration: status === "online" ? 2600 : Infinity,
duration: status === "online" ? 2600 : status === "degraded" ? 8000 : Infinity,
actionLabel,
onAction
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseOperationalTask } from "./operational-task-client";
vi.mock("./project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })),
createWorkbenchHeaders: vi.fn(() => ({})),
getServerError: vi.fn(() => "请求失败")
}));
const id = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const time = "2026-09-23T01:00:00Z";
function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
execution_request_id: id, title: "水质投诉", summary: "调查中",
source: { kind: "external_system", external_id: null, external_revision: null, event_kind: null },
trigger: { kind: "event", occurred_at: time, detail: "投诉上报" },
scheduled_for: null, agent_session_id: "session", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1, created_at: time, updated_at: time,
latest_plan: { ...snapshot, plan: { schema: "execution-plan@1", title: "调查", reason: "投诉",
nodes: [{ id: "check", title: "核查", owner: "agent", status: "completed", detail: "已核查", evidence_refs: [], action_refs: [] }], edges: [] } },
latest_report: { ...snapshot, report: { title: "调查报告", current_conclusion: "待采样",
conclusion_boundary: "尚无现场证据", evidence_refs: [], recommendations: [], pending_items: ["采样"] } },
evidence: [], actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false,
can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false,
can_decide_commands: false, can_execute_commands: false }
};
}
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
it("accepts execution generation on both plan and report", () => {
const parsed = parseOperationalTask(task());
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
expect(parsed.latest_report).toHaveProperty("execution_generation", 2);
});
it("accepts legacy null or missing snapshot generation", () => {
expect(() => parseOperationalTask(task(null))).not.toThrow();
const legacy = task();
Reflect.deleteProperty(legacy.latest_plan, "execution_generation");
Reflect.deleteProperty(legacy.latest_report, "execution_generation");
expect(() => parseOperationalTask(legacy)).not.toThrow();
});
it("accepts a task without any plan or report", () => {
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
});
it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
});
});
@@ -0,0 +1,395 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]);
const workbenchDateTimeSchema = z.string().datetime({ offset: true });
const taskSourceSchema = z.object({
kind: z.enum(["schedule", "external_system", "operator", "agent"]),
external_id: z.string().min(1).nullable(),
external_revision: z.number().int().positive().nullable(),
event_kind: z.string().min(1).nullable()
}).strict();
const taskTriggerSchema = z.object({
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
occurred_at: workbenchDateTimeSchema,
detail: z.string().min(1)
}).strict();
const operationalTaskRunSchema = z.object({
run_id: z.string().uuid(),
task_id: z.string().uuid(),
generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
revision: z.number().int().positive(),
agent_session_id: z.string().min(1).nullable(),
lease_expires_at: workbenchDateTimeSchema.nullable(),
heartbeat_at: workbenchDateTimeSchema.nullable(),
started_at: workbenchDateTimeSchema.nullable(),
finished_at: workbenchDateTimeSchema.nullable(),
failure_code: z.string().min(1).nullable(),
failure_detail: z.string().min(1).nullable(),
dispatch_attempts: z.number().int().nonnegative(),
next_dispatch_at: workbenchDateTimeSchema,
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const taskSummarySchema = z.object({
project_id: z.string().uuid(),
task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
workflow: z.string().min(1),
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
active_run: operationalTaskRunSchema.nullable(),
status: taskStatusSchema,
priority: taskPrioritySchema,
revision: z.number().int().positive(),
execution_generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
title: z.string().min(1),
summary: z.string().min(1),
source: taskSourceSchema,
trigger: taskTriggerSchema,
scheduled_for: workbenchDateTimeSchema.nullable(),
agent_session_id: z.string().min(1).nullable(),
agent_lease_expires_at: workbenchDateTimeSchema.nullable(),
current_plan_revision: z.number().int().positive().nullable(),
current_report_revision: z.number().int().positive().nullable(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const evidenceSchema = z.object({
evidence_id: z.string().min(1),
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
summary: z.string().min(1),
source_label: z.string().min(1),
source_ref: z.string().min(1).nullable(),
observed_at: workbenchDateTimeSchema,
recorded_at: workbenchDateTimeSchema,
recorded_by: z.string().min(1),
has_snapshot: z.boolean()
}).strict();
const planNodeSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
owner: z.enum(["agent", "human"]),
status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]),
detail: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
action_refs: z.array(z.string().uuid())
}).strict();
const planInputSchema = z.object({
schema: z.literal("execution-plan@1"),
title: z.string().min(1),
reason: z.string().min(1),
nodes: z.array(planNodeSchema).min(1).max(100),
edges: z.array(z.object({
from: z.string().min(1),
to: z.string().min(1),
condition: z.string().min(1).nullable()
}).strict()).max(300)
}).strict();
const planSnapshotSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
plan: planInputSchema
}).strict();
const reportInputSchema = z.object({
title: z.string().min(1),
current_conclusion: z.string().min(1),
conclusion_boundary: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
recommendations: z.array(z.string().min(1)),
pending_items: z.array(z.string().min(1))
}).strict();
const reportRevisionSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
report: reportInputSchema
}).strict();
const actionReceiptSchema = z.object({
adapter: z.string().min(1),
receipt_id: z.string().min(1),
submitted_at: workbenchDateTimeSchema,
completed_at: workbenchDateTimeSchema.nullable(),
external_status: z.string().min(1),
detail: z.string().min(1)
}).strict();
const actionSchema = z.object({
action_id: z.string().uuid(),
task_id: z.string().uuid(),
version: z.number().int().positive(),
kind: z.enum(["create_work_order", "issue_command"]),
reason: z.string().min(1),
payload: z.record(z.string(), z.unknown()),
evidence_refs: z.array(z.string().min(1)),
node_id: z.string().min(1).nullable(),
required_approval_permission: z.string().min(1),
required_execution_permission: z.string().min(1),
approval_status: z.enum(["pending", "approved", "rejected"]),
execution_status: z.enum(["not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"]),
execution_request_id: z.string().uuid().nullable(),
proposed_by: z.string().min(1),
proposed_at: workbenchDateTimeSchema,
decided_by: z.string().min(1).nullable(),
decided_at: workbenchDateTimeSchema.nullable(),
decision_comment: z.string().min(1).nullable(),
executed_by: z.string().min(1).nullable(),
executed_at: workbenchDateTimeSchema.nullable(),
receipt: actionReceiptSchema.nullable(),
execution_detail: z.string().min(1).nullable()
}).strict();
const taskPermissionsSchema = z.object({
can_publish_artifacts: z.boolean(),
can_propose_actions: z.boolean(),
can_sync_external_feedback: z.boolean(),
can_decide_work_orders: z.boolean(),
can_execute_work_orders: z.boolean(),
can_decide_commands: z.boolean(),
can_execute_commands: z.boolean()
}).strict();
const externalWorkOrderStageSchema = z.object({
id: z.string().min(1),
sequence: z.number().int().positive(),
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)
}).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({
work_order_id: z.string().uuid(),
revision: z.number().int().positive(),
title: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"]),
priority: z.enum(["low", "normal", "high", "urgent"]),
assignee: z.string().min(1).nullable(),
scheduled_at: workbenchDateTimeSchema.nullable(),
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(),
origin: z.literal("synthetic"),
updated_at: workbenchDateTimeSchema
}).strict();
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
evidence: z.array(evidenceSchema),
actions: z.array(actionSchema),
permissions: taskPermissionsSchema,
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
}).strict();
const notificationSchema = z.object({
notification_id: z.string().uuid(),
task_id: z.string().uuid(),
kind: z.enum(["task_created", "action_pending", "report_updated", "task_completed", "task_failed", "run_delivery_degraded"]),
title: z.string().min(1),
description: z.string().min(1),
status: z.enum(["unread", "read"]),
version: z.number().int().positive(),
required_permission: z.string().min(1).nullable(),
created_at: workbenchDateTimeSchema,
read_at: workbenchDateTimeSchema.nullable(),
read_by: z.string().min(1).nullable()
}).strict();
const notificationPageSchema = z.object({
items: z.array(notificationSchema),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable()
}).strict();
const scheduleSchema = z.object({
schedule_id: z.string().uuid(),
schedule_key: z.string().min(1),
title: z.string().min(1),
summary: z.string().min(1),
interval_seconds: z.number().int().min(60).max(31_536_000),
next_run_at: workbenchDateTimeSchema,
priority: taskPrioritySchema,
enabled: z.boolean(),
version: z.number().int().positive(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict();
const taskDataSnapshotSchema = z.object({
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data"]),
source_ref: z.string().min(1),
queried_at: workbenchDateTimeSchema,
origin: z.enum(["system_data", "synthetic"]),
data: z.unknown(),
truncated: z.boolean()
}).strict();
export type OperationalTaskSummary = z.infer<typeof taskSummarySchema>;
export type OperationalTask = z.infer<typeof operationalTaskSchema>;
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
export type OperationalTaskAction = z.infer<typeof actionSchema>;
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
export type OperationalTaskDecision = "approved" | "rejected";
export function parseOperationalTask(value: unknown) {
return operationalTaskSchema.parse(value);
}
export function parseOperationalTaskSummary(value: unknown) {
return taskSummarySchema.parse(value);
}
export function parseOperationalTaskNotification(value: unknown) {
return notificationSchema.parse(value);
}
export type CreateOperationalTaskInput = {
request_id: string;
task_type: OperationalTask["task_type"];
workflow: string;
title: string;
summary: string;
priority: OperationalTask["priority"];
source: OperationalTask["source"];
trigger: OperationalTask["trigger"];
scheduled_for?: string;
agent_session_id?: string;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
return payload;
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}`,
getAccessToken,
{ signal }
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function decideOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
decision: OperationalTaskDecision;
comment?: string;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return notificationPageSchema.parse(await operationalTaskRequest(
"/operational-task-notifications?unread_only=true&limit=100&offset=0",
getAccessToken,
{ signal }
)).items;
}
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
await operationalTaskRequest(
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
getAccessToken,
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
);
}
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return schedulePageSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ signal }
)).items;
}
export async function createOperationalTaskSchedule(input: {
request_id: string;
schedule_key: string;
title: string;
summary: string;
interval_seconds: number;
next_run_at: string;
priority: "normal" | "urgent";
}, getAccessToken?: AccessTokenProvider) {
return scheduleSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
getAccessToken
));
}
export function createOperationalRequestId() {
return crypto.randomUUID();
}
@@ -36,6 +36,37 @@ describe("workbench project client", () => {
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("does not share a caller-bound abort signal with other project consumers", async () => {
const projectPage = JSON.stringify({
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
});
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
if (!init?.signal) {
return Promise.resolve(new Response(projectPage, { status: 200 }));
}
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
});
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = async () => null;
const controller = new AbortController();
const cancelled = findOwnedWorkbenchProject(getAccessToken, controller.signal);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const active = findOwnedWorkbenchProject(getAccessToken);
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("loads the authoritative project extent and access identity independently", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
+19 -1
View File
@@ -69,12 +69,19 @@ export type WorkbenchProjectPresentation = {
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const projectContextPromises = new WeakMap<AccessTokenProvider, Promise<WorkbenchProject>>();
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const cacheable = Boolean(getAccessToken && !signal);
const existing = cacheable && getAccessToken
? projectContextPromises.get(getAccessToken)
: undefined;
if (existing) return { project: await existing, accessToken };
const loadProject = async () => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
signal
@@ -86,7 +93,18 @@ export async function findOwnedWorkbenchProject(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return { project, accessToken };
return project;
};
const pending = loadProject();
if (cacheable && getAccessToken) projectContextPromises.set(getAccessToken, pending);
try {
return { project: await pending, accessToken };
} catch (error) {
if (cacheable && getAccessToken && projectContextPromises.get(getAccessToken) === pending) {
projectContextPromises.delete(getAccessToken);
}
throw error;
}
}
export async function fetchWorkbenchProjectPresentation(
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchAnalysisRun,
fetchWorkbenchRuntimeSnapshot
parseWorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const projectPage = {
@@ -89,16 +89,8 @@ const runtimeSnapshot = {
describe("workbench runtime API client", () => {
afterEach(() => vi.unstubAllGlobals());
it("resolves the project and maps a persisted scheduled condition", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(runtimeSnapshot), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot(
vi.fn().mockResolvedValue("access-token")
);
it("maps a persisted scheduled condition", () => {
const snapshot = parseWorkbenchRuntimeSnapshot(runtimeSnapshot);
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.conditions).toEqual([
@@ -140,17 +132,10 @@ describe("workbench runtime API client", () => {
})
]));
expect(snapshot.simulationOverlay?.features).toHaveLength(1);
const runtimeHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(runtimeHeaders.get("Authorization")).toBe("Bearer access-token");
expect(runtimeHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/workbench/runtime");
});
it("keeps legacy analysis conditions out of the operational task feed", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("keeps legacy analysis conditions out of the operational task feed", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
source: "analysis_runs",
conditions: [{
@@ -168,10 +153,7 @@ describe("workbench runtime API client", () => {
evidence: ["运行 ID:analysis-1"],
model_name: "TJWater analysis runtime"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.source).toBe("none");
expect(snapshot.conditions).toEqual([]);
@@ -182,16 +164,8 @@ describe("workbench runtime API client", () => {
expect(snapshot.scenarios).toHaveLength(1);
});
it("rejects an untyped runtime payload instead of treating it as real data", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...runtimeSnapshot, source: "production" }), {
status: 200
}));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchRuntimeSnapshot()).rejects.toThrow();
it("rejects an untyped runtime payload instead of treating it as real data", () => {
expect(() => parseWorkbenchRuntimeSnapshot({ ...runtimeSnapshot, source: "production" })).toThrow();
});
it("loads the requested analysis run instead of reusing the latest result", async () => {
@@ -247,11 +221,8 @@ describe("workbench runtime API client", () => {
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("drops invalid backend map geometry without discarding other real sections", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("drops invalid backend map geometry without discarding other real sections", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
latest_analysis: {
...runtimeSnapshot.latest_analysis,
@@ -264,10 +235,7 @@ describe("workbench runtime API client", () => {
}]
}
}
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.scenarios).toHaveLength(1);
@@ -276,7 +244,7 @@ describe("workbench runtime API client", () => {
expect(snapshot.latestAnalysisArtifact).not.toHaveProperty("spatialOverlay");
});
it("maps provisioned backend work orders without using the demo shape", async () => {
it("maps provisioned backend work orders without using the demo shape", () => {
const backendWorkOrder = {
id: "wo-1",
code: "WO-20260917-001",
@@ -334,21 +302,15 @@ describe("workbench runtime API client", () => {
reply_requirements: ["回传阀门开度"]
}]
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
work_order_availability: "ready",
work_orders: [backendWorkOrder]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.workOrderAvailability).toBe("ready");
expect(snapshot.workOrders).toEqual([expect.objectContaining({
id: "wo-1",
id: "demo-work-order:wo-1",
kind: "work_order",
code: "WO-20260917-001",
status: "completed",
@@ -364,11 +326,8 @@ describe("workbench runtime API client", () => {
})]);
});
it("maps a provisioned backend alert without inventing a condition link", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("maps a provisioned backend alert without inventing a condition link", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
alert_availability: "ready",
alerts: [{
@@ -384,10 +343,7 @@ describe("workbench runtime API client", () => {
occurred_at: "2026-09-17T02:29:00Z",
severity: "warning"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.alertAvailability).toBe("ready");
expect(snapshot.alerts).toEqual([expect.objectContaining({
@@ -202,19 +202,7 @@ export type WorkbenchAnalysisProjection = {
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchRuntimeSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchRuntimeSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/runtime`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "工作台运行态请求"));
}
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload);
const latestAnalysis = snapshot.latest_analysis
? projectWorkbenchAnalysis(snapshot.latest_analysis)
@@ -274,7 +262,7 @@ export async function fetchWorkbenchRuntimeSnapshot(
latestAnalysisArtifact: latestAnalysis?.artifact ?? null,
simulationOverlay: latestAnalysis?.simulationOverlay ?? null,
workOrders: snapshot.work_orders.map((workOrder) => ({
id: workOrder.id,
id: `demo-work-order:${workOrder.id}`,
kind: "work_order",
code: workOrder.code,
scheduledAt: workOrder.scheduled_at,
@@ -289,6 +277,7 @@ export async function fetchWorkbenchRuntimeSnapshot(
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "backend",
subtasks: workOrder.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { consumeWorkbenchEvents } from "./workbench-sync-client";
vi.mock("./project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({
project: { project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e" },
accessToken: "test-token"
})),
createWorkbenchHeaders: vi.fn((token: string, projectId: string) => new Headers({
Authorization: `Bearer ${token}`,
"X-Project-Id": projectId
})),
getServerError: vi.fn(() => "请求失败")
}));
afterEach(() => vi.unstubAllGlobals());
describe("workbench event stream", () => {
it("sends the authenticated cursor and stops for a snapshot reset", async () => {
const payload = "event: reset_required\r\ndata: {}\r\n\r\n";
const bytes = new TextEncoder().encode(payload);
let request: Request | undefined;
vi.stubGlobal("fetch", vi.fn(async (input, init) => {
request = new Request(input, init);
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, 17));
controller.enqueue(bytes.slice(17));
controller.close();
}
}), { status: 200, headers: { "Content-Type": "text/event-stream" } });
}));
const onReset = vi.fn(async () => undefined);
const onEvents = vi.fn();
await expect(consumeWorkbenchEvents({
after: 41,
signal: new AbortController().signal,
onReset,
onEvents
})).resolves.toBe("reset");
expect(request?.headers.get("Authorization")).toBe("Bearer test-token");
expect(request?.headers.get("X-Project-Id")).toBe("324ff7ee-c2d4-5184-a84b-48fb14a2933e");
expect(request?.headers.get("Last-Event-ID")).toBe("41");
expect(new URL(request!.url).searchParams.get("after")).toBe("41");
expect(onReset).toHaveBeenCalledOnce();
expect(onEvents).not.toHaveBeenCalled();
});
it("delivers every event from one stream chunk as an ordered batch", async () => {
const makeEvent = (eventSequence: number) => ({
schema: "workbench-event@1",
event_seq: eventSequence,
event_id: `72000000-0000-4000-8000-${String(eventSequence).padStart(12, "0")}`,
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
topic: "operational.task.changed",
aggregate_type: "operational_task",
aggregate_id: `task-${eventSequence}`,
aggregate_revision: 3,
occurred_at: "2026-09-23T08:30:00Z",
metadata: { task_id: `task-${eventSequence}` }
});
const events = [makeEvent(42), makeEvent(43)];
const payload = events.map((event) =>
`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`
).join("");
vi.stubGlobal("fetch", vi.fn(async () => new Response(payload, {
status: 200,
headers: { "Content-Type": "text/event-stream" }
})));
const onEvents = vi.fn();
await expect(consumeWorkbenchEvents({
after: 41,
signal: new AbortController().signal,
onReset: vi.fn(async () => undefined),
onEvents
})).resolves.toBe("closed");
expect(onEvents).toHaveBeenCalledOnce();
expect(onEvents).toHaveBeenCalledWith(events);
});
});
@@ -0,0 +1,127 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
parseOperationalTaskNotification,
parseOperationalTaskSummary,
type OperationalTaskNotification,
type OperationalTaskSummary
} from "./operational-task-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import {
parseWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const snapshotEnvelopeSchema = z.object({
schema: z.literal("workbench-sync-snapshot@1"),
event_cursor: z.number().int().nonnegative(),
runtime: z.unknown(),
tasks: z.array(z.unknown()),
notifications: z.array(z.unknown()),
generated_at: z.string().datetime({ offset: true })
}).strict();
const eventSchema = z.object({
schema: z.literal("workbench-event@1"),
event_seq: z.number().int().positive(),
event_id: z.string().uuid(),
project_id: z.string().uuid(),
topic: z.enum([
"operational.task.changed",
"operational.run.queued",
"operational.run.changed",
"operational.report.published",
"operational.action.changed",
"operational.notification.changed",
"workbench.alert.changed",
"workbench.work_order.changed",
"analysis.run.changed"
]),
aggregate_type: z.string().min(1),
aggregate_id: z.string().min(1),
aggregate_revision: z.number().int().positive(),
occurred_at: z.string().datetime({ offset: true }),
metadata: z.record(z.string(), z.unknown())
}).strict();
export type WorkbenchEvent = z.infer<typeof eventSchema>;
export type WorkbenchSyncSnapshot = {
eventCursor: number;
runtime: WorkbenchRuntimeSnapshot;
tasks: OperationalTaskSummary[];
notifications: OperationalTaskNotification[];
generatedAt: string;
};
export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload);
return {
eventCursor: envelope.event_cursor,
runtime: parseWorkbenchRuntimeSnapshot(envelope.runtime),
tasks: envelope.tasks.map(parseOperationalTaskSummary),
notifications: envelope.notifications.map(parseOperationalTaskNotification),
generatedAt: envelope.generated_at
};
}
export async function consumeWorkbenchEvents(input: {
after: number;
getAccessToken?: AccessTokenProvider;
onEvents: (events: WorkbenchEvent[]) => void;
onReset: () => Promise<void>;
signal: AbortSignal;
}): Promise<"closed" | "reset"> {
const { project, accessToken } = await findOwnedWorkbenchProject(input.getAccessToken, input.signal);
const headers = createWorkbenchHeaders(accessToken, project.project_id);
headers.set("Accept", "text/event-stream");
headers.set("Last-Event-ID", String(input.after));
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/events?after=${input.after}`,
{ headers, signal: input.signal }
);
if (!response.ok || !response.body) {
const payload: unknown = await response.json().catch(() => null);
throw new Error(getServerError(payload, response.status, "工作台事件连接"));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!input.signal.aborted) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
if (buffer.length > 1_048_576) throw new Error("工作台事件帧超过大小限制");
const events: WorkbenchEvent[] = [];
let boundary = buffer.search(/\r?\n\r?\n/);
while (boundary >= 0) {
const separator = buffer.slice(boundary).match(/^\r?\n\r?\n/)?.[0] ?? "\n\n";
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + separator.length);
const lines = block.split(/\r?\n/);
const eventName = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
if (eventName === "reset_required") {
await input.onReset();
return "reset";
}
if (eventName === "workbench_event" && data) events.push(eventSchema.parse(JSON.parse(data)));
boundary = buffer.search(/\r?\n\r?\n/);
}
if (events.length > 0) input.onEvents(events);
if (done) return "closed";
}
return "closed";
}
@@ -0,0 +1,283 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { type AgentExecutionPlan } from "@/features/agent";
import type { OperationalTask } from "../api/operational-task-client";
import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel";
describe("execution step cards", () => {
it("shows parallel cards, exact join dependencies and escaped model content", () => {
const plan: AgentExecutionPlan = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验",
nodes: ["接入", "上游", "邻区", "汇合"].map((title, index) => ({
id: String(index), title, status: index === 3 ? "waiting" : "completed",
owner: index === 3 ? "human" : "agent", detail: '<script>alert("test")</script>', evidence: []
})),
edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByText("并行处理")).toBeInTheDocument();
expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch");
expect(screen.getByText("2 条独立分枝")).toBeInTheDocument();
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
expect(container.querySelector("script")).toBeNull();
expect(screen.queryByText(/前置:/)).not.toBeInTheDocument();
expect(screen.getByText("分枝 1")).toBeInTheDocument();
expect(screen.getByText("分枝 2")).toBeInTheDocument();
expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument();
expect(screen.getByText("待人工/资料")).toBeInTheDocument();
});
it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => {
const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
});
const plan: AgentExecutionPlan = {
schema_version: "execution-plan@1", session_id: "s-nested", revision: 1,
updated_at: "2026-09-23T01:00:00.000Z", title: "多轮调查", reason: "按真实依赖推进",
nodes: [
node("start", "接收事件"),
node("a1", "上游核查"), node("a2", "上游复核"),
node("b1", "邻区投诉"),
node("c1", "压力比对"), node("c2", "水力校核"), node("c3", "阀门排查"),
node("d1", "运维记录"),
node("join1", "阶段判断"),
node("e1", "二次供水核查", "running"), node("e2", "水箱记录", "waiting"),
node("f1", "现场采样", "waiting"),
node("join2", "形成结论", "pending")
],
edges: [
{ from: "start", to: "a1", label: "" }, { from: "a1", to: "a2", label: "" }, { from: "a2", to: "join1", label: "" },
{ from: "start", to: "b1", label: "" }, { from: "b1", to: "join1", label: "" },
{ from: "start", to: "c1", label: "" }, { from: "c1", to: "c2", label: "" }, { from: "c2", to: "c3", label: "" }, { from: "c3", to: "join1", label: "" },
{ from: "start", to: "d1", label: "" }, { from: "d1", to: "join1", label: "" },
{ from: "join1", to: "e1", label: "" }, { from: "e1", to: "e2", label: "" }, { from: "e2", to: "join2", label: "" },
{ from: "join1", to: "f1", label: "" }, { from: "f1", to: "join2", label: "" }
]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible();
expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible();
expect(screen.getAllByText("分枝汇合")).toHaveLength(2);
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument();
});
});
const taskBase = {
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation",
workflow: "water_quality_complaint",
lifecycle_status: "waiting_human",
active_run: null,
status: "waiting_human",
priority: "normal",
revision: 2,
execution_generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
title: "水质投诉调查",
summary: "居民反映异味",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
trigger: { kind: "manual", occurred_at: "2026-09-23T01:00:00Z", detail: "演示小区" },
scheduled_for: null,
agent_session_id: "execution-session",
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: 1,
created_at: "2026-09-23T01:00:00Z",
updated_at: "2026-09-23T01:10:00Z",
latest_plan: null,
latest_report: {
revision: 1, execution_generation: 1, source_session_id: "execution-session", published_at: "2026-09-23T01:09:00Z", published_by: "agent",
report: { title: "调查报告", current_conclusion: "上游指标正常,待核查二次供水。", conclusion_boundary: "等待现场采样。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] }
},
evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }],
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false },
work_order_snapshot: null
} as OperationalTask;
function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<typeof AgentExecutionPlanPanel>> = {}) {
const props = {
task,
onContinue: vi.fn(),
onDecision: vi.fn(async () => undefined),
onExecute: vi.fn(async () => undefined),
onReconcile: vi.fn(async () => undefined),
onRefresh: vi.fn(),
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
...overrides
};
render(<AgentExecutionPlanPanel {...props} />);
return props;
}
describe("operational task approval and evidence", () => {
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
taskPanel(taskBase);
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
const previousReport = {
...taskBase,
status: "running" as const,
execution_generation: 2,
latest_report: { ...taskBase.latest_report!, execution_generation: 1 }
};
const { rerender } = render(<AgentExecutionPlanPanel
task={previousReport}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "failed" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible();
expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "cancelled" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, execution_generation: null } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("历史报告", { exact: true })).toBeVisible();
});
it("labels current, previous and legacy execution plans without presenting old progress as current", () => {
const latestPlan: NonNullable<OperationalTask["latest_plan"]> = {
revision: 1,
execution_generation: 1,
source_session_id: "execution-session",
published_at: "2026-09-23T01:05:00Z",
published_by: "agent",
plan: {
schema: "execution-plan@1",
title: "调查流程",
reason: "核对真实状态",
nodes: [{
id: "investigate",
title: "核查事件",
owner: "agent",
status: "completed",
detail: "上一轮已完成。",
evidence_refs: [],
action_refs: []
}],
edges: []
}
};
const currentPlanTask = { ...taskBase, latest_plan: latestPlan };
const props = {
onContinue: vi.fn(), onDecision: vi.fn(), onExecute: vi.fn(), onReconcile: vi.fn(),
onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn()
};
const { rerender } = render(<AgentExecutionPlanPanel task={currentPlanTask} {...props} />);
expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "running", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible();
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "failed", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
{...props}
/>);
expect(screen.getByText("历史流程", { exact: true })).toBeVisible();
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
});
it("shows exact payload and evidence metadata to a viewer without action controls", () => {
taskPanel(taskBase);
expect(screen.getByText("工单标题")).toBeVisible();
expect(screen.getByText("位置")).toBeVisible();
expect(screen.getByText("预约时间")).toBeVisible();
expect(screen.getByText("现场采样")).toBeVisible();
expect(screen.getByText("演示小区")).toBeVisible();
expect(screen.getByText("确认依据")).toBeVisible();
expect(screen.getByText(/上游余氯处于正常范围 · SCADA 水质监测/)).toBeVisible();
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "批准创建工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("上游余氯处于正常范围"));
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
expect(screen.getByText("系统数据")).toBeVisible();
});
it("keeps pending state after a decision network error", async () => {
const onDecision = vi.fn(async () => { throw new Error("network unavailable"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理");
expect(screen.getByText("待确认")).toBeVisible();
});
it("requires refresh and reconfirmation after a version conflict", async () => {
const onRefresh = vi.fn();
const onDecision = vi.fn(async () => { throw new Error("409 version conflict"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision, onRefresh });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
const refresh = await screen.findByRole("button", { name: "刷新后重新确认" });
fireEvent.click(refresh);
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce());
});
it("allows unknown submissions to reconcile and shows a persisted receipt", async () => {
const onReconcile = vi.fn(async () => undefined);
const unknownAction = {
...taskBase.actions[0],
approval_status: "approved" as const,
execution_status: "unknown" as const,
execution_request_id: "55555555-5555-4555-8555-555555555555"
};
const receiptAction = {
...unknownAction,
execution_status: "succeeded" as const,
receipt: {
adapter: "peripheral",
receipt_id: "receipt-20260923-01",
submitted_at: "2026-09-23T01:10:00Z",
completed_at: "2026-09-23T01:11:00Z",
external_status: "completed",
detail: "外围工单创建完成"
}
};
const { rerender } = render(<AgentExecutionPlanPanel
task={{ ...taskBase, actions: [unknownAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
fireEvent.click(screen.getByRole("button", { name: "核对执行回执" }));
await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version));
rerender(<AgentExecutionPlanPanel
task={{ ...taskBase, actions: [receiptAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01");
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成");
});
});
@@ -0,0 +1,555 @@
import { useMemo, useState, type ReactNode } from "react";
import {
AlertTriangle,
CheckCircle2,
Clock3,
FileText,
GitFork,
Loader2,
MessageSquare,
RefreshCw,
Route,
ShieldCheck,
UserCheck
} from "lucide-react";
import type { AgentExecutionPlan } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input";
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskDecision,
OperationalTaskEvidence,
OperationalTaskPlan,
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
type ExecutionNode = {
id: string;
title: string;
status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled";
owner: "agent" | "human";
detail: string;
};
type ExecutionEdge = {
from: string;
to: string;
label?: string | null;
condition?: string | null;
};
export type ExecutionPlanView = {
nodes: ExecutionNode[];
edges: ExecutionEdge[];
};
type AgentExecutionPlanPanelProps = {
task: OperationalTask;
onContinue: () => void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
};
export function AgentExecutionPlanPanel({
task,
onContinue,
onDecision,
onExecute,
onReconcile,
onLoadEvidenceSnapshot,
onRefresh
}: AgentExecutionPlanPanelProps) {
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation;
const planState = planGeneration == null
? "historical"
: planGeneration === task.execution_generation
? "current"
: "previous";
const reportGeneration = task.latest_report?.execution_generation;
const reportState = reportGeneration == null
? "historical"
: reportGeneration === task.execution_generation
? "current"
: "previous";
const completed = plan?.nodes.filter((node) => node.status === "completed").length ?? 0;
const active = plan?.nodes.filter((node) => node.status === "running").length ?? 0;
const waiting = plan?.nodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length ?? 0;
const externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback");
const taskActive = task.status === "queued" || task.status === "running";
const planSummary = !plan
? "任务已持久化,等待 Agent 发布首版处理计划。"
: planState === "current"
? `已完成 ${completed} 项 · 执行中 ${active} 项 · 待人工或资料 ${waiting} 项`
: planState === "previous"
? taskActive
? "上一轮流程,本轮规划中"
: `上一轮流程,当前任务${taskStatusLabel(task.status)}`
: "历史流程";
const reportLabel = reportState === "current"
? "当前结论"
: reportState === "previous"
? taskActive
? "上一轮报告,本轮分析中"
: `上一轮报告,当前任务${taskStatusLabel(task.status)}`
: "历史报告";
const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [
node.id,
task.actions.filter((action) => action.node_id === node.id).map((action) => (
<HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} onExecute={onExecute} onReconcile={onReconcile} onRefresh={onRefresh} />
))
])) : {};
const unlinkedActions = task.actions.filter((action) => !action.node_id || !plan?.nodes.some((node) => node.id === action.node_id));
return (
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
<section className="surface-reading overflow-hidden rounded-xl border border-slate-200/70">
<div className="space-y-2 border-b border-slate-200/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800">
<Route size={13} aria-hidden="true" />
{task.task_type === "external_work_order" ? "外部工单进度" : "调查处理流程"}
</h3>
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? "success" : "info"}>
{taskStatusLabel(task.status)}
</StatusBadge>
</div>
<p className="text-xs leading-5 text-slate-600" role="status">
{planSummary}
</p>
<p className="text-[11px] leading-5 text-slate-500">
{taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)}
</p>
</div>
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
<ExternalProgress evidence={externalEvidence} />
) : plan ? (
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
) : (
<p className="p-3 text-xs leading-5 text-slate-500">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
)}
</section>
<section aria-label="任务报告" className="surface-reading space-y-3 rounded-xl border border-slate-200/70 p-3">
<div>
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
<p className="mt-1 text-[11px] leading-5 text-slate-500">结论与证据独立呈现,不展示模型推理或节点执行日志。</p>
</div>
{report ? (
<>
<div className={cn(
"rounded-lg px-3 py-2.5",
reportState === "current" ? "bg-blue-50/70" : "bg-amber-50/80"
)}>
<p className={cn(
"text-[11px] font-semibold",
reportState === "current" ? "text-blue-700" : "text-amber-800"
)}>
{reportLabel}
</p>
<p className="mt-1 text-xs leading-6 text-slate-800">{report.current_conclusion}</p>
</div>
{report.recommendations.length > 0 ? (
<ReportList title="处置建议" items={report.recommendations} tone="blue" />
) : null}
{report.pending_items.length > 0 ? (
<ReportList title="待确认事项" items={report.pending_items} tone="amber" />
) : null}
<p className="border-t border-slate-200/70 pt-2 text-[11px] leading-5 text-slate-500">结论边界:{report.conclusion_boundary}</p>
</>
) : (
<p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>
)}
</section>
<EvidenceSection evidence={task.evidence} referencedIds={report?.evidence_refs ?? []} onLoadSnapshot={onLoadEvidenceSnapshot} />
{unlinkedActions.length > 0 ? (
<section aria-label="待人工确认动作" className="space-y-2.5">
{unlinkedActions.map((action) => (
<HumanActionCard
key={action.action_id}
task={task}
action={action}
onDecision={onDecision}
onExecute={onExecute}
onReconcile={onReconcile}
onRefresh={onRefresh}
/>
))}
</section>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onContinue}>
<MessageSquare size={14} aria-hidden="true" />Agent 协作
</Button>
</div>
</section>
);
}
function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) {
return (
<div>
<p className="text-[11px] font-semibold text-slate-600">{title}</p>
<ul className="mt-1.5 grid gap-1.5">
{items.map((item) => (
<li key={item} className="grid grid-cols-[6px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", tone === "blue" ? "bg-blue-500" : "bg-amber-400")} aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record<string, ReactNode> }) {
return <div aria-label="调查执行步骤" className="px-3 py-3"><ExecutionFlow plan={plan} nodeActions={nodeActions} depth={0} /></div>;
}
function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; depth: number }) {
const layout = createBranchLayout(plan);
if (!layout || depth > 8) return <ExactDependencyList plan={plan} nodeActions={nodeActions} />;
return (
<div className="grid gap-2">
<ExecutionChain nodes={layout.prefix} plan={plan} nodeActions={nodeActions} />
{layout.branches.length > 0 ? (
<section aria-label={`并行分枝 ${layout.branches.length} 项`} data-execution-layout="branch" className="min-w-0 py-1">
<div className="mb-2 flex items-center gap-2">
<StatusIcon tone="info" shape="circle" size="md"><GitFork size={14} /></StatusIcon>
<span className="text-xs font-semibold text-slate-800">并行处理</span>
<StatusBadge tone="info">{layout.branches.length} 条独立分枝</StatusBadge>
</div>
<div className="grid snap-x snap-mandatory auto-cols-[86%] grid-flow-col gap-2 overflow-x-auto overscroll-x-contain pb-1 sm:snap-none sm:grid-flow-row sm:grid-cols-2 sm:overflow-visible">
{layout.branches.map((branch, index) => {
const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id);
return (
<section key={branch.map((node) => node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5">
<p className="mb-2 text-[11px] font-semibold text-slate-500">分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}</p>
<ExecutionChain nodes={branch} plan={plan} nodeActions={nodeActions} compact />
</section>
);
})}
</div>
</section>
) : null}
{layout.suffix.length > 0 ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-[11px] text-slate-500"><span className="h-px flex-1 bg-slate-200" /><span>分枝汇合</span><span className="h-px flex-1 bg-slate-200" /></div>
<ExecutionFlow plan={subPlan(plan, layout.suffix.map((node) => node.id))} nodeActions={nodeActions} depth={depth + 1} />
</div>
) : null}
</div>
);
}
function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; compact?: boolean }) {
return <div className="grid gap-0">{nodes.map((node, index) => {
const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined;
return <div key={node.id}>{index > 0 ? <div className="flex min-h-6 items-center gap-2 px-3 text-[10px] text-slate-500"><span className="h-6 w-px bg-slate-300" />{edge?.condition ? <span>{edge.condition}</span> : null}</div> : null}<ExecutionNodeCard node={node} action={nodeActions[node.id]} compact={compact} /></div>;
})}</div>;
}
function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
return <ol aria-label="调查执行步骤" className="grid gap-2">{topologicalNodes(plan).map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} /></li>)}</ol>;
}
function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView {
const included = new Set(nodeIds);
return {
nodes: plan.nodes.filter((node) => included.has(node.id)),
edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to))
};
}
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
const { tone, Icon, label } = executionNodePresentation(node);
return (
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-slate-200/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}>
<div className="flex min-w-0 items-start gap-2">
<StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div>
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
{action ? <div className="mt-2">{action}</div> : null}
</div>
</div>
</article>
);
}
function ExternalProgress({ evidence }: { evidence: OperationalTaskEvidence[] }) {
const ordered = [...evidence].sort((left, right) => Date.parse(left.observed_at) - Date.parse(right.observed_at));
return (
<ol className="grid gap-2 p-3">
{ordered.map((item, index) => (
<li key={item.evidence_id} className="grid grid-cols-[28px_1fr] gap-2">
<span className="relative flex justify-center">
{index < ordered.length - 1 ? <span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" /> : null}
<StatusIcon tone="info" shape="circle" size="md" className="relative z-10 ring-4 ring-white"><Clock3 size={14} /></StatusIcon>
</span>
<article className="surface-reading rounded-xl border border-slate-200/70 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold text-slate-900">{item.source_label}</span><span className="font-mono text-[11px] text-slate-500">{formatDateTime(item.observed_at)}</span></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{item.summary}</p>
</article>
</li>
))}
</ol>
);
}
function EvidenceSection({ evidence, referencedIds, onLoadSnapshot }: { evidence: OperationalTaskEvidence[]; referencedIds: string[]; onLoadSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot> }) {
const ordered = useMemo(() => {
const referenced = new Set(referencedIds);
return [...evidence].sort((left, right) => Number(referenced.has(right.evidence_id)) - Number(referenced.has(left.evidence_id)));
}, [evidence, referencedIds]);
return (
<section aria-label="关键证据" className="surface-reading rounded-xl border border-slate-200/70 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={13} />关键证据</h3><span className="text-[11px] text-slate-500">{ordered.length} 条</span></div>
{ordered.length > 0 ? <div className="mt-2 grid gap-1.5">{ordered.map((item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadSnapshot} />)}</div> : <p className="mt-2 text-xs leading-5 text-slate-500">尚无可核验的支撑证据。</p>}
</section>
);
}
function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvidence; onLoadSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot> }) {
const [snapshot, setSnapshot] = useState<OperationalTaskDataSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true); setError(null);
try { setSnapshot(await onLoadSnapshot(item.evidence_id)); }
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); }
finally { setLoading(false); }
};
return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-slate-200/70 px-2.5">
<div className="border-t border-slate-200/70 py-2">
<dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl>
{item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
{snapshot ? <SnapshotSummary snapshot={snapshot} /> : null}
</div>
</Disclosure>;
}
function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) {
const rows = snapshotRows(snapshot.data).slice(0, 12);
return <section aria-label="数据快照" className="mt-2 rounded-lg bg-white px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-amber-700">数据已按有界查询范围截断。</p> : null}</section>;
}
function snapshotRows(value: unknown, prefix = ""): [string, string][] {
if (Array.isArray(value)) return value.slice(0, 6).flatMap((item, index) => snapshotRows(item, `${prefix}${index + 1}`));
if (value && typeof value === "object") return Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => snapshotRows(item, prefix ? `${prefix}.${key}` : key));
return [[prefix || "值", value === null || value === undefined ? "空" : String(value)]];
}
function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-slate-500">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
}
function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onRefresh }: {
task: OperationalTask;
action: OperationalTaskAction;
onDecision: AgentExecutionPlanPanelProps["onDecision"];
onExecute: AgentExecutionPlanPanelProps["onExecute"];
onReconcile: AgentExecutionPlanPanelProps["onReconcile"];
onRefresh: () => void;
}) {
const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | null>(null);
const [error, setError] = useState<string | null>(null);
const [stale, setStale] = useState(false);
const [rejectComment, setRejectComment] = useState("");
const command = action.kind === "issue_command";
const canDecide = command ? task.permissions.can_decide_commands : task.permissions.can_decide_work_orders;
const canExecute = command ? task.permissions.can_execute_commands : task.permissions.can_execute_work_orders;
const run = async (kind: "approve" | "reject" | "execute" | "reconcile") => {
setSubmitting(kind); setError(null);
try {
if (kind === "execute") await onExecute(action.action_id, action.version);
else if (kind === "reconcile") await onReconcile(action.action_id, action.version);
else {
if (kind === "reject" && !rejectComment.trim()) {
setError("请填写退回理由后再拒绝动作。");
return;
}
await onDecision(action.action_id, action.version, kind === "approve" ? "approved" : "rejected", kind === "reject" ? rejectComment.trim() : undefined);
}
} catch (nextError) {
const message = nextError instanceof Error ? nextError.message : "人工动作提交失败。";
setError(message);
setStale(/version|版本|409|conflict/i.test(message));
} finally { setSubmitting(null); }
};
return (
<article className="surface-reading rounded-xl border border-amber-200/80 p-3" data-action-id={action.action_id}>
<div className="flex items-start gap-2.5">
<StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-slate-500">原因:</span>{action.reason}</p>
<ActionPayloadSummary payload={action.payload} />
{action.evidence_refs.length > 0 ? (
<div className="mt-2 rounded-lg bg-blue-50/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
<p className="font-semibold text-blue-700">确认依据</p>
<ul className="mt-1 grid gap-1">
{action.evidence_refs.map((evidenceId) => {
const evidence = task.evidence.find((item) => item.evidence_id === evidenceId);
return <li key={evidenceId}>{evidence ? `${evidence.summary} · ${evidence.source_label}` : `证据 ${evidenceId}`}</li>;
})}
</ul>
</div>
) : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
<Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-slate-200/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-slate-200/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure>
<p className="mt-2 text-[11px] leading-5 text-slate-500">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
{error ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{error} 当前状态未按成功处理。</p> : null}
{stale ? <Button variant="outline" size="sm" className="mt-2" onClick={() => { setStale(false); onRefresh(); }}><RefreshCw size={14} />刷新后重新确认</Button> : null}
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button><Button size="sm" disabled={submitting !== null} onClick={() => void run("approve")}>{submitting === "approve" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : "批准创建工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建工单"}</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown", "failed", "unsupported"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div>
</div>
</div>
</article>
);
}
function ActionReceipt({ receipt }: { receipt: NonNullable<OperationalTaskAction["receipt"]> }) {
return <section aria-label="执行回执" className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
<p className="text-xs font-semibold text-emerald-800">执行回执</p>
<dl className="mt-1 grid gap-1 text-[11px] leading-5">
<EvidenceMeta label="回执编号" value={receipt.receipt_id} mono />
<EvidenceMeta label="外部状态" value={externalStatusLabel(receipt.external_status)} />
<EvidenceMeta label="提交时间" value={formatDateTime(receipt.submitted_at)} />
{receipt.completed_at ? <EvidenceMeta label="完成时间" value={formatDateTime(receipt.completed_at)} /> : null}
<EvidenceMeta label="说明" value={receipt.detail} />
</dl>
</section>;
}
function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> }) {
const rows = Object.entries(payload).flatMap(([key, value]) => {
if (key !== "parameters" && value && typeof value === "object" && !Array.isArray(value)) {
return Object.entries(value as Record<string, unknown>).map(([childKey, childValue]) => ({ key: `${key}.${childKey}`, value: childValue }));
}
return [{ key, value }];
});
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-slate-200/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-slate-500">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
}
function payloadLabel(key: string) {
const labels: Record<string, string> = {
title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级",
scheduled_at: "预约时间", location: "位置", assignee: "处理班组", command_type: "命令类型",
"target.kind": "对象类型", "target.id": "目标对象", parameters: "执行参数", impact_summary: "影响说明",
preconditions: "执行条件", expires_at: "失效时间"
};
return labels[key] ?? key;
}
function payloadValue(key: string, value: unknown) {
if (value === null || value === undefined || value === "") return "未指定";
if (key === "command_type") return knownLabel(value, { set_valve: "调节阀门" });
if (key === "target.kind") return knownLabel(value, { valve: "阀门", pump: "泵站", pipe: "管段" });
if (key === "work_order_type") return knownLabel(value, { general: "一般工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" });
if (key === "priority") return knownLabel(value, { normal: "普通", urgent: "紧急", high: "高", low: "低" });
if (key === "scheduled_at" || key === "expires_at") return typeof value === "string" ? formatDateTime(value) : String(value);
if (Array.isArray(value)) return value.map(String).join("、") || "无";
if (key === "parameters" && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).map(([parameter, item]) =>
parameter === "opening" ? `开度 ${String(item)}%` : `${parameter}=${String(item)}`
).join(";");
}
if (typeof value === "object") return Object.entries(value as Record<string, unknown>).map(([itemKey, item]) => `${itemKey}=${String(item)}`).join(";");
return String(value);
}
function knownLabel(value: unknown, labels: Record<string, string>) {
const raw = String(value);
return labels[raw] ?? raw;
}
function externalStatusLabel(value: string) {
return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record<string, string>)[value] ?? value;
}
function createBranchLayout(plan: ExecutionPlanView) {
const ordered = topologicalNodes(plan);
if (ordered.length !== plan.nodes.length) return null;
const outgoing = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.from === node.id)]));
const incoming = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.to === node.id)]));
const fork = ordered.find((node) => (outgoing.get(node.id)?.length ?? 0) > 1);
if (!fork) return { prefix: ordered, branches: [] as ExecutionNode[][], suffix: [] as ExecutionNode[] };
const starts = outgoing.get(fork.id)!.map((edge) => edge.to);
const descendants = starts.map((start) => reachableFrom(start, outgoing));
const join = ordered.find((node) => node.id !== fork.id && (incoming.get(node.id)?.length ?? 0) > 1 && descendants.every((set) => set.has(node.id)));
if (!join) return null;
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const branches: ExecutionNode[][] = [];
for (const start of starts) {
const branch: ExecutionNode[] = [];
let current = start;
const seen = new Set<string>();
while (current !== join.id && !seen.has(current)) {
seen.add(current);
const node = nodeById.get(current);
if (!node) return null;
branch.push(node);
const nextEdges = outgoing.get(current) ?? [];
if (nextEdges.length !== 1) return null;
current = nextEdges[0].to;
}
if (current !== join.id || branch.length === 0) return null;
branches.push(branch);
}
const branchIds = new Set(branches.flatMap((branch) => branch.map((node) => node.id)));
const forkIndex = ordered.findIndex((node) => node.id === fork.id);
const joinIndex = ordered.findIndex((node) => node.id === join.id);
return { prefix: ordered.slice(0, forkIndex + 1), branches, suffix: ordered.slice(joinIndex).filter((node) => !branchIds.has(node.id)) };
}
function reachableFrom(start: string, outgoing: Map<string, ExecutionEdge[]>) {
const seen = new Set<string>();
const stack = [start];
while (stack.length > 0) { const current = stack.pop()!; if (seen.has(current)) continue; seen.add(current); for (const edge of outgoing.get(current) ?? []) stack.push(edge.to); }
return seen;
}
function topologicalNodes(plan: ExecutionPlanView) {
const remaining = new Map(plan.nodes.map((node) => [node.id, node]));
const result: ExecutionNode[] = [];
while (remaining.size > 0) {
const ready = plan.nodes.filter((node) => remaining.has(node.id) && plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
if (ready.length === 0) return result;
ready.forEach((node) => { result.push(node); remaining.delete(node.id); });
}
return result;
}
function executionNodePresentation(node: ExecutionNode) {
const tone: StatusTone = node.status === "completed" ? "success" : node.status === "running" ? "info" : node.status === "failed" ? "danger" : node.status === "waiting" ? "warning" : "neutral";
const Icon = node.status === "completed" ? CheckCircle2 : node.status === "running" ? Loader2 : node.status === "waiting" || node.status === "failed" ? AlertTriangle : Clock3;
const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "不适用", cancelled: "已取消" } as const;
return { tone, Icon, label: labels[node.status] };
}
function taskTypeLabel(type: OperationalTask["task_type"]) { return { scheduled_flow: "定时流程", external_work_order: "外部工单", event_investigation: "事件调查" }[type]; }
function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; }
function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; }
function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); }
function approvalLabel(status: OperationalTaskAction["approval_status"]) { return { pending: "待确认", approved: "已批准", rejected: "已拒绝" }[status]; }
function approvalTone(status: OperationalTaskAction["approval_status"]): StatusTone { return status === "approved" ? "success" : status === "rejected" ? "danger" : "warning"; }
function executionLabel(status: OperationalTaskAction["execution_status"]) { return { not_submitted: "未提交", submitted: "已提交", succeeded: "执行成功", failed: "执行失败", unsupported: "未接执行器", unknown: "结果未知" }[status]; }
function executionTone(status: OperationalTaskAction["execution_status"]): StatusTone { return status === "succeeded" ? "success" : status === "failed" || status === "unsupported" ? "danger" : status === "submitted" || status === "unknown" ? "warning" : "neutral"; }
@@ -68,6 +68,12 @@ function createConditionStepView(
runningConditionCount: number,
dataSource: WorkbenchRuntimeDataSource
): ConditionStepTickerView {
if (condition.taskId === "water-quality-complaint") {
return {
title: condition.title,
currentStep: { id: "agent-plan", title: "Agent 执行计划", detail: "打开任务查看实际节点状态与等待事项。" }
};
}
const effectiveDataSource = condition.dataSource ?? dataSource;
if (effectiveDataSource !== "mock") {
return {
@@ -1,7 +1,9 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord } from "../types";
import { ConditionDetailPanel } from "./scheduled-condition-detail-panel";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import { ConditionDetailPanel, WorkOrderSubtasksPanel } from "./scheduled-condition-detail-panel";
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
const baseCondition: ScheduledConditionRecord = {
id: "schedule-run-1",
@@ -21,9 +23,61 @@ const baseCondition: ScheduledConditionRecord = {
modelName: "TJWater scheduler"
};
it("does not infer water-quality progress or conversation steps from elapsed time", () => {
const condition: ScheduledConditionRecord = { ...baseCondition, taskId: "water-quality-complaint", dataSource: "mock", status: "running" };
const [card] = createVisibleTickerCards([condition], 0, Date.now());
expect(card.view.progressValue).toBeUndefined();
expect(card.view.currentStep?.title).toBe("Agent 执行计划");
expect(createConditionConversationPrompt(condition)).not.toContain("工作流步骤");
});
describe("scheduled condition backend boundaries", () => {
afterEach(cleanup);
it("keeps Chinese work-order time labels in the application font", () => {
const workOrder: ScheduledWorkOrderItem = {
id: "demo-work-order",
kind: "work_order",
code: "WO-DEMO",
scheduledAt: "2026-09-23T10:00:00Z",
title: "演示预约维修",
summary: "验证中文时间标签",
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-23T10:00:00Z"),
source: "演示数据",
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: ["回填处理结果"]
}]
};
render(<WorkOrderSubtasksPanel condition={workOrder} />);
const timeLabel = screen.getByText("待安排", { exact: true });
expect(timeLabel).toHaveClass("tabular-nums");
expect(timeLabel).not.toHaveClass("font-mono");
});
it("shows only persisted evidence for a failed backend condition", () => {
render(
<ConditionDetailPanel
@@ -57,6 +111,25 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.queryByText("演示进度")).not.toBeInTheDocument();
});
it("does not expose operational-task model, session or duration metadata", () => {
render(
<ConditionDetailPanel
condition={{
...baseCondition,
dataSource: "operational_tasks",
modelName: "TJWater operational task",
sessionId: "browser-session-1"
}}
dataSource="operational_tasks"
onContinueConversation={vi.fn()}
/>
);
expect(screen.queryByText("TJWater operational task")).not.toBeInTheDocument();
expect(screen.queryByText("browser-session-1")).not.toBeInTheDocument();
expect(screen.queryByText("未记录")).not.toBeInTheDocument();
});
it("keeps the mock work-order interaction but labels it as a local demo draft", () => {
render(
<ConditionDetailPanel
@@ -81,7 +154,7 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument();
});
it("renders an event-triggered water-quality task as a workflow before opening chat", () => {
it("does not expose the removed mock Agent execution path", () => {
const onContinueConversation = vi.fn();
render(
<ConditionDetailPanel
@@ -103,10 +176,9 @@ describe("scheduled condition backend boundaries", () => {
/>
);
expect(screen.getByText("演示执行流程 · 水质投诉调查流程")).toBeVisible();
expect(screen.getAllByText("校验上游水质监测").at(-1)).toBeVisible();
expect(screen.getAllByText("核查二次供水").at(-1)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "与 Agent 协同" }));
expect(onContinueConversation).toHaveBeenCalledOnce();
expect(screen.getByText("后端运行状态")).toBeVisible();
expect(screen.queryByText(/演示执行流程/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "与 Agent 协同" })).not.toBeInTheDocument();
expect(onContinueConversation).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,12 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { AgentExecutionPlanPanel } from "./agent-execution-plan-panel";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type {
ExternalWorkOrderSnapshot,
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import {
@@ -105,6 +111,11 @@ type ConditionDetailPanelProps = {
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
};
export function StatusPill({
@@ -115,23 +126,24 @@ export function StatusPill({
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={
isWorkOrder
isAgentEvent ? "neutral" : isWorkOrder
? getWorkOrderStageTone(currentStage?.status)
: getStatusTone(condition.status)
}
activity={
isWorkOrder
isAgentEvent ? "static" : isWorkOrder
? getWorkOrderStageActivity(currentStage?.status)
: getStatusActivity(condition.status)
}
className={className}
>
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
{isAgentEvent ? "事件调查" : isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -140,7 +152,12 @@ export function ConditionDetailPanel({
condition,
dataSource = "mock",
workOrderDataSource = "mock",
onContinueConversation
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
}: ConditionDetailPanelProps) {
const detailKey =
condition.kind === "work_order"
@@ -148,13 +165,21 @@ export function ConditionDetailPanel({
: condition.id;
const content =
condition.kind === "work_order" ? (
<WorkOrderDetailPanel condition={condition} dataSource={workOrderDataSource} />
<WorkOrderDetailPanel
condition={condition}
dataSource={condition.dataSource ?? workOrderDataSource}
/>
) : (
<ConditionRecordDetailPanel
condition={condition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={onContinueConversation}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
/>
);
@@ -178,12 +203,22 @@ function ConditionRecordDetailPanel({
condition,
dataSource,
workOrderDataSource,
onContinueConversation
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
}: {
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
onTaskRefresh?: () => void;
}) {
const backendCondition = (condition.dataSource ?? dataSource) !== "mock";
const insight = backendCondition ? null : getConditionAnalysisInsight(condition);
@@ -191,6 +226,9 @@ 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
? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot)
: null;
function handleSubmitIncidentWorkOrder() {
const nextWorkOrder = createSubmittedIncidentWorkOrder(condition);
@@ -222,7 +260,41 @@ function ConditionRecordDetailPanel({
exit={{ opacity: 0, y: -8, scale: 0.996 }}
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
>
{condition.status === "running" ? (
{condition.operationalTask ? (
<>
{externalWorkOrder ? <WorkOrderSubtasksPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot?.subtasks ? (
<DetailSectionCard>
<SectionTitle icon={ListChecks} title="预约过程" />
<p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单过程,不根据总状态推测阶段。</p>
</DetailSectionCard>
) : null}
<AgentExecutionPlanPanel
task={condition.operationalTask}
onContinue={onContinueConversation}
onDecision={(actionId, version, decision, comment) => {
if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。"));
return onTaskDecision(condition.operationalTask!.task_id, actionId, version, decision, comment);
}}
onExecute={(actionId, version) => {
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
return onTaskExecute(condition.operationalTask!.task_id, actionId, version);
}}
onReconcile={(actionId, version) => {
if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。"));
return onTaskReconcile(condition.operationalTask!.task_id, actionId, version);
}}
onLoadEvidenceSnapshot={(evidenceId) => {
if (!onLoadTaskEvidenceSnapshot) return Promise.reject(new Error("当前未配置证据快照接口。"));
return onLoadTaskEvidenceSnapshot(condition.operationalTask!.task_id, evidenceId);
}}
onRefresh={onTaskRefresh ?? (() => undefined)}
/>
</>
) : condition.taskId === "water-quality-complaint" ? (
<PersistedRunningConditionBody condition={condition} />
) : condition.status === "running" ? (
backendCondition ? (
<PersistedRunningConditionBody condition={condition} />
) : (
@@ -397,7 +469,7 @@ function WorkOrderDetailPanel({
/>
<DetailDecisionBody>
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "工单演示回退"}
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend"
? "工单的子任务、阶段、责任人和复令要求均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
@@ -513,6 +585,8 @@ function DetailHeader({
iconClassName,
iconSpin = false
}: DetailHeaderProps) {
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks";
return (
<div className="px-3 py-3">
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
@@ -524,7 +598,7 @@ function DetailHeader({
iconClassName
)}
>
<Icon size={16} aria-hidden="true" className={iconSpin ? "animate-spin" : undefined} />
<Icon size={16} aria-hidden="true" className={iconSpin && !isAgentEvent ? "animate-spin" : undefined} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -538,7 +612,7 @@ function DetailHeader({
</div>
</div>
<DetailMetricGrid condition={condition} />
{!isAgentEvent && !isOperationalTask ? <DetailMetricGrid condition={condition} /> : null}
</div>
</div>
);
@@ -1201,7 +1275,7 @@ function TaskExecutionStepRow({
{step.meta.map((item) => (
<span key={`${item.label}-${item.value}`} className="contents">
<span className="font-semibold text-slate-400">{item.label}</span>
<span className={item.label === "时间" ? "font-mono text-slate-700" : "text-slate-700"}>{item.value}</span>
<span className={item.label === "时间" ? "tabular-nums text-slate-700" : "text-slate-700"}>{item.value}</span>
</span>
))}
</span>
@@ -1212,7 +1286,7 @@ function TaskExecutionStepRow({
);
}
function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
export function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const completedCount = condition.subtasks.filter((subtask) =>
subtask.status === "completed" || subtask.status === "cancelled"
@@ -1303,6 +1377,55 @@ function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderIt
);
}
function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem {
return {
id: snapshot.work_order_id,
kind: "work_order",
code: snapshot.scenario_code ?? snapshot.work_order_id,
scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at,
title: snapshot.title,
summary: snapshot.summary,
status: snapshot.status === "in_progress"
? "running"
: snapshot.status === "awaiting_reply"
? "awaiting_reply"
: snapshot.status === "completed"
? "completed"
: snapshot.status === "cancelled"
? "cancelled"
: "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
}))
};
}
function getWorkOrderSubtaskTone(status: ScheduledWorkOrderItem["subtasks"][number]["status"]): StatusTone {
if (status === "completed") return "success";
if (status === "running") return "info";
@@ -19,6 +19,7 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
cancelled: "已取消",
warning: "关注",
error: "异常",
pending: "预约"
@@ -28,6 +29,7 @@ const statusTones = {
completed: "success",
running: "info",
awaiting_reply: "warning",
cancelled: "neutral",
warning: "warning",
error: "danger",
pending: "neutral"
@@ -37,6 +39,7 @@ const statusActivities = {
completed: "static",
running: "loading",
awaiting_reply: "static",
cancelled: "static",
warning: "static",
error: "static",
pending: "static"
@@ -46,6 +49,7 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
awaiting_reply: Clock3,
cancelled: XCircle,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
@@ -73,6 +77,7 @@ const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"warning",
"error",
"completed",
"cancelled",
"pending"
];
@@ -1,5 +1,6 @@
import {
CalendarClock,
CalendarPlus,
ChevronDown,
ChevronRight,
ClipboardList,
@@ -17,6 +18,8 @@ import type {
WorkbenchWorkOrderDataSource
} from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { OperationalTaskDataSnapshot } from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
@@ -26,7 +29,18 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
WORKBENCH_TASK_CATEGORIES,
getWorkbenchTaskCategory,
groupConditionsByTaskCategory,
type WorkbenchTaskCategory
} from "./task-category";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
@@ -56,12 +70,22 @@ export type ScheduledConditionFeedPresentation =
| "desktop-floating"
| "mobile-sheet";
export type ScheduleCreateRequest = {
scheduleKey: string;
title: string;
summary: string;
intervalSeconds: number;
nextRunAt: string;
};
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
backendLoading?: boolean;
backendError?: string | null;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
visible?: boolean;
@@ -73,6 +97,13 @@ type ScheduledConditionFeedProps = {
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
};
type ScheduledConditionFeedFocusRequest = {
@@ -86,6 +117,8 @@ export function ScheduledConditionFeed({
expanded: controlledExpanded,
focusRequest,
loading = false,
backendLoading = false,
backendError = null,
dataSource = "mock",
workOrderDataSource = "mock",
visible = true,
@@ -96,7 +129,14 @@ export function ScheduledConditionFeed({
onExpandAgent,
onClose,
onLoadHistorySession,
onSubmitPrompt
onSubmitPrompt,
onOpenTaskCollaboration,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onCreateSchedule
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
@@ -105,27 +145,46 @@ export function ScheduledConditionFeed({
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleTitle, setScheduleTitle] = useState("");
const [scheduleKey, setScheduleKey] = useState("scada-diagnosis");
const [scheduleSummary, setScheduleSummary] = useState("");
const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15");
const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15");
const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000)));
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null);
const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
const conditionsByCategory = useMemo(
() => groupConditionsByTaskCategory(conditions),
[conditions]
);
const categoryConditions = conditionsByCategory[taskCategory];
const activeLoading = taskCategory === "scheduled" ? loading : backendLoading;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(categoryConditions),
[categoryConditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilterOptions = useMemo(
() => createTaskFilterOptions(categoryConditions),
[categoryConditions]
);
const taskFilteredConditions = useMemo(
() =>
conditions.filter(
categoryConditions.filter(
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
),
[conditions, taskFilter]
[categoryConditions, taskFilter]
);
const statusFilterOptions = useMemo(
() => createStatusFilterOptions(taskFilteredConditions),
@@ -150,24 +209,9 @@ export function ScheduledConditionFeed({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const conditionSourceLabel = {
mock: "工况演示数据",
backend: "后端当天数据",
scheduled_tasks: "后端定时任务"
}[dataSource];
const workOrderSourceLabel = {
mock: "工单演示回退",
backend: "后端工单",
none: "未接入工单"
}[workOrderDataSource];
const sourceLabel = `${conditionSourceLabel} · ${workOrderSourceLabel}`;
const headerSummary = loading
const headerSummary = loading && backendLoading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条 · ${sourceLabel}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`;
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
@@ -222,20 +266,24 @@ export function ScheduledConditionFeed({
return;
}
const targetCondition = allTimelineConditions.find(
const targetCondition = conditions.find(
(condition) => condition.id === focusConditionId
);
if (!targetCondition) {
return;
}
const targetCategory = getWorkbenchTaskCategory(targetCondition);
if (!targetCategory) return;
setTaskCategory(targetCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [
allTimelineConditions,
conditions,
focusConditionId,
focusRequestId,
onFocusRequestHandled,
@@ -250,6 +298,42 @@ export function ScheduledConditionFeed({
);
}
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
setTaskCategory(nextCategory);
if (nextCategory !== "scheduled") setScheduleOpen(false);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null);
}
function handleToggleSchedule() {
if (!scheduleOpen) {
setTaskCategory("scheduled");
updateExpanded(true);
}
setScheduleOpen((current) => !current);
}
async function handleCreateSchedule() {
const title = scheduleTitle.trim();
const summary = scheduleSummary.trim();
const key = scheduleKey.trim();
const intervalMinutes = Number(scheduleIntervalMinutes);
const nextRunAt = new Date(scheduleNextRunAt);
if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; }
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; }
if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; }
if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; }
setScheduleSubmitting(true); setScheduleError(null);
try {
await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() });
setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary("");
showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" });
} catch (error) {
setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。");
} finally { setScheduleSubmitting(false); }
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
@@ -262,6 +346,18 @@ export function ScheduledConditionFeed({
return;
}
if (condition.operationalTask) {
onExpandAgent();
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id))
.catch((error) => showMapNotice({
id: "operational-task-collaboration",
tone: "error",
title: "Agent 协作打开失败",
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
}));
return;
}
onExpandAgent();
if (!condition.sessionId) {
@@ -346,6 +442,17 @@ export function ScheduledConditionFeed({
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
{onCreateSchedule ? (
<button
type="button"
aria-label="配置定时流程"
aria-expanded={scheduleOpen}
onClick={handleToggleSchedule}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<CalendarPlus size={17} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-expanded={expanded}
@@ -375,6 +482,38 @@ export function ScheduledConditionFeed({
) : null}
</header>
{expanded ? (
<TaskCategorySwitch
activeCategory={taskCategory}
counts={{
scheduled: conditionsByCategory.scheduled.length,
investigation: conditionsByCategory.investigation.length,
appointment: conditionsByCategory.appointment.length
}}
onChange={handleTaskCategoryChange}
/>
) : null}
{scheduleOpen ? (
<section aria-label="配置定时流程" className={cn("mt-2 p-3", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<label className="block text-xs font-medium text-slate-600">定时流程名称<Input className="mt-1 h-9" placeholder="例如夜间低压巡检" value={scheduleTitle} onChange={(event) => setScheduleTitle(event.target.value)} /></label>
<div className="mt-2"><FieldSelect label="执行流程" value={scheduleKey} onValueChange={setScheduleKey} options={[{ value: "scada-diagnosis", label: "SCADA 数据诊断" }, { value: "smart-dispatch", label: "管网智能调度" }, { value: "network-simulation", label: "管网定时模拟" }, { value: "pump-energy", label: "泵站能耗检查" }]} /></div>
<label className="mt-2 block text-xs font-medium text-slate-600">任务说明<Textarea className="mt-1 min-h-16" placeholder="说明本流程核查什么以及预期输出" value={scheduleSummary} onChange={(event) => setScheduleSummary(event.target.value)} /></label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<FieldSelect label="执行周期" value={scheduleIntervalPreset} onValueChange={(value) => { setScheduleIntervalPreset(value); if (value !== "custom") setScheduleIntervalMinutes(value); }} options={[{ value: "5", label: "每 5 分钟" }, { value: "15", label: "每 15 分钟" }, { value: "30", label: "每 30 分钟" }, { value: "60", label: "每 60 分钟" }, { value: "custom", label: "自定义周期" }]} />
{scheduleIntervalPreset === "custom" ? <label className="mt-2 block text-xs font-medium text-slate-600">自定义分钟数<Input className="mt-1 h-9" inputMode="numeric" value={scheduleIntervalMinutes} onChange={(event) => setScheduleIntervalMinutes(event.target.value.replace(/\D/g, ""))} /></label> : null}
</div>
<DateTimeField label="下次执行时间" value={scheduleNextRunAt} onChange={setScheduleNextRunAt} />
</div>
{scheduleError ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{scheduleError}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" disabled={scheduleSubmitting} onClick={() => setScheduleOpen(false)}>取消</Button>
<Button type="button" size="sm" disabled={scheduleSubmitting} onClick={() => void handleCreateSchedule()}>{scheduleSubmitting ? "创建中" : "创建定时流程"}</Button>
</div>
</section>
) : null}
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
@@ -398,7 +537,7 @@ export function ScheduledConditionFeed({
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
{activeLoading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
@@ -406,6 +545,11 @@ export function ScheduledConditionFeed({
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
/>
) : null}
</div>
@@ -418,7 +562,17 @@ export function ScheduledConditionFeed({
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
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}
taskFilter={taskFilter}
@@ -443,6 +597,54 @@ export function ScheduledConditionFeed({
);
}
function TaskCategorySwitch({
activeCategory,
counts,
onChange
}: {
activeCategory: WorkbenchTaskCategory;
counts: Record<WorkbenchTaskCategory, number>;
onChange: (category: WorkbenchTaskCategory) => void;
}) {
return (
<nav
aria-label="工况任务分类"
className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mt-2 grid grid-cols-3 gap-1 p-1")}
>
{WORKBENCH_TASK_CATEGORIES.map((category) => {
const active = category.id === activeCategory;
return (
<button
key={category.id}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onChange(category.id)}
className={cn(
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200",
active
? "bg-white text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-800"
)}
>
<span className="flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-semibold">{category.label}</span>
<span className="shrink-0 text-xs tabular-nums text-slate-400">{counts[category.id]}</span>
</span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-slate-400">
{category.sourceLabel}
</span>
</button>
);
})}
</nav>
);
}
function formatLocalDateTimeInput(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
@@ -457,6 +659,8 @@ type ConditionTimelinePanelProps = {
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
emptyTitle?: string;
emptyDescription?: string;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
@@ -477,6 +681,8 @@ function ConditionTimelinePanel({
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
emptyTitle,
emptyDescription,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
@@ -545,11 +751,11 @@ function ConditionTimelinePanel({
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
title={emptyTitle ?? (filterActive ? "没有符合筛选的任务" : "暂无任务")}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
emptyDescription ?? (filterActive
? "调整任务或状态筛选后查看其他记录。"
: "新任务进入当前分类后会出现在这里。")
}
/>
)}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { OperationalTask } from "../api/operational-task-client";
import type { ScheduledConditionItem, ScheduledConditionRecord } from "../types";
import {
getWorkbenchTaskCategory,
groupConditionsByTaskCategory
} from "./task-category";
function condition(
id: string,
taskType?: OperationalTask["task_type"]
): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId: taskType ? "operational-task" : "scada-diagnosis",
sessionId: id,
scheduledAt: "2026-09-23T09:00:00+08:00",
title: id,
summary: id,
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-23T09:00:00+08:00"),
operationalTask: taskType ? ({ task_type: taskType } as OperationalTask) : undefined
};
}
describe("workbench task categories", () => {
it("keeps scheduled mock tasks separate from backend investigations and appointments", () => {
const scheduled = condition("scheduled");
const investigation = condition("investigation", "event_investigation");
const appointment = condition("appointment", "external_work_order");
const projectedComplaint = {
...condition("projected-complaint", "external_work_order"),
operationalTask: {
...condition("projected-complaint", "external_work_order").operationalTask!,
source: { kind: "external_system", external_id: "wo-1", external_revision: 1, event_kind: "water_quality_complaint" }
}
} as ScheduledConditionRecord;
const backendSchedule = condition("backend-schedule", "scheduled_flow");
const demoWorkOrder = { kind: "work_order", id: "demo" } as ScheduledConditionItem;
expect(getWorkbenchTaskCategory(scheduled)).toBe("scheduled");
expect(getWorkbenchTaskCategory(investigation)).toBe("investigation");
expect(getWorkbenchTaskCategory(appointment)).toBe("appointment");
expect(getWorkbenchTaskCategory(projectedComplaint)).toBeNull();
expect(getWorkbenchTaskCategory(backendSchedule)).toBeNull();
expect(getWorkbenchTaskCategory(demoWorkOrder)).toBe("appointment");
expect(groupConditionsByTaskCategory([
scheduled,
investigation,
appointment,
projectedComplaint,
backendSchedule,
demoWorkOrder
])).toEqual({
scheduled: [scheduled],
investigation: [investigation],
appointment: [appointment, demoWorkOrder]
});
});
});
@@ -0,0 +1,63 @@
import type { ScheduledConditionItem } from "../types";
export type WorkbenchTaskCategory = "scheduled" | "investigation" | "appointment";
export const WORKBENCH_TASK_CATEGORIES = [
{
id: "scheduled",
label: "定时任务",
sourceLabel: "模拟数据"
},
{
id: "investigation",
label: "调查任务",
sourceLabel: "后端"
},
{
id: "appointment",
label: "预约任务",
sourceLabel: "后端 / 演示"
}
] as const satisfies readonly {
id: WorkbenchTaskCategory;
label: string;
sourceLabel: string;
}[];
export function getWorkbenchTaskCategory(
condition: ScheduledConditionItem
): WorkbenchTaskCategory | null {
if (condition.kind === "work_order") return "appointment";
const taskType = condition.operationalTask?.task_type;
if (taskType === "event_investigation") return "investigation";
if (taskType === "external_work_order") {
const eventKind = condition.operationalTask?.source?.event_kind;
const workOrderType = condition.operationalTask?.work_order_snapshot?.work_order_type;
if (
eventKind === "water_quality_complaint"
|| eventKind === "burst_report"
|| workOrderType === "water_quality_complaint"
|| workOrderType === "burst_report"
) return null;
return "appointment";
}
if (taskType) return null;
return "scheduled";
}
export function groupConditionsByTaskCategory(conditions: ScheduledConditionItem[]) {
const grouped: Record<WorkbenchTaskCategory, ScheduledConditionItem[]> = {
scheduled: [],
investigation: [],
appointment: []
};
conditions.forEach((condition) => {
const category = getWorkbenchTaskCategory(condition);
if (category) grouped[category].push(condition);
});
return grouped;
}
@@ -18,7 +18,7 @@ export type RunningWorkflowDefinition = {
export type RunningWorkflowStepStatus = "done" | "current" | "pending";
export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, RunningWorkflowDefinition> = {
export const runningWorkflowDefinitions: Partial<Record<ScheduledConditionTaskId, RunningWorkflowDefinition>> = {
"scada-diagnosis": {
title: "SCADA 诊断流程",
reportLabel: "生成诊断报告",
@@ -142,37 +142,6 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
detail: "输出启停优化、压力设定和继续观察条件。"
}
]
},
"water-quality-complaint": {
title: "水质投诉调查流程",
reportLabel: "形成调查结论",
steps: [
{
id: "accept-work-order",
title: "接收投诉工单",
detail: "读取投诉类型、发生位置、时间和问题描述,建立事件与工单的可追溯关联。"
},
{
id: "check-upstream-quality",
title: "校验上游水质监测",
detail: "核查上游监测点的数据完整性、时效性及关键水质指标是否处于正常范围。"
},
{
id: "locate-supply-segment",
title: "研判供水链路",
detail: "若上游存在异常,转入水厂或管网排查;若上游正常,继续核查小区二次供水环节。"
},
{
id: "check-secondary-supply",
title: "核查二次供水",
detail: "检查水箱、泵房、消毒记录、清洗记录及同区域投诉,判断是否为局部设施问题。"
},
{
id: "prepare-disposition",
title: "汇总结论与处置",
detail: "整理证据、责任环节和建议动作,明确需要人工确认、采样或现场处置的事项。"
}
]
}
};
@@ -352,6 +352,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "mock" as const,
subtasks: createWorkOrderSubtasks(workOrder, now, scheduledAtDate, phase)
};
})
@@ -16,6 +16,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
@@ -184,6 +185,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
if (
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
@@ -275,6 +277,7 @@ function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
appendLegacyDataParts(parts, "permission_request", message.permissions);
appendLegacyDataParts(parts, "question_request", message.questions);
appendLegacyDataParts(parts, "todo_update", message.todos);
appendLegacyDataParts(parts, "execution_plan", message.executionPlans);
return {
id: message.id,
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
clearMapFeatureInteractionState,
isPointOccludedByCityBuilding,
setMapFeatureInteractionState,
toMapFeatureReference
} from "./use-map-interactions";
@@ -8,6 +9,7 @@ import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
describe("map feature interaction state", () => {
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
type CityOcclusionMap = Parameters<typeof isPointOccludedByCityBuilding>[0];
it("maps selected business features to promoted vector feature references", () => {
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
@@ -42,4 +44,30 @@ describe("map feature interaction state", () => {
"fx_20200423_pipes-hit"
]));
});
it("blocks pointer interactions where a 3D building is rendered", () => {
const queryRenderedFeatures = vi.fn(() => [{ id: "building-1" }]);
const map = {
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
getPitch: vi.fn(() => 55),
queryRenderedFeatures
};
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(true);
expect(queryRenderedFeatures).toHaveBeenCalledWith([120, 80], {
layers: ["fengxian-city-buildings"]
});
});
it("keeps 2D pointer interactions independent of the optional city scene", () => {
const queryRenderedFeatures = vi.fn();
const map = {
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
getPitch: vi.fn(() => 0),
queryRenderedFeatures
};
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(false);
expect(queryRenderedFeatures).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { Map as MapLibreMap, MapLayerMouseEvent, PointLike } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import type { DetailFeature } from "../types";
@@ -8,6 +8,7 @@ import {
toDetailFeature
} from "../map/feature-adapter";
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
import { CITY_BUILDING_LAYER_ID } from "../map/city-scene-layers";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
@@ -46,6 +47,16 @@ export function clearMapFeatureInteractionState(
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type CityOcclusionMap = Pick<
MapLibreMap,
"getLayer" | "getPitch" | "queryRenderedFeatures"
>;
export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: PointLike) {
if (map.getPitch() <= 0 || !map.getLayer(CITY_BUILDING_LAYER_ID)) return false;
return map.queryRenderedFeatures(point, { layers: [CITY_BUILDING_LAYER_ID] }).length > 0;
}
type UseMapInteractionsOptions = {
availableSourceIds: readonly WaterNetworkSourceId[];
mapRef: RefObject<MapLibreMap | null>;
@@ -74,6 +85,11 @@ export function useMapInteractions({
};
const handleMouseMove = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) {
clearHoveredFeature();
map.getCanvas().style.cursor = "";
return;
}
const feature = event.features?.[0];
const source = feature && getWaterNetworkSourceId(feature);
const id = feature && getFeatureId(feature);
@@ -100,6 +116,7 @@ export function useMapInteractions({
};
const handleClick = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) return;
const feature = event.features?.[0];
if (feature) onSelectFeature(toDetailFeature(feature));
};
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { OperationalTask } from "../api/operational-task-client";
import { toOperationalTaskCondition } from "./use-operational-tasks";
function task(taskType: OperationalTask["task_type"]): OperationalTask {
return {
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType,
workflow: "general",
lifecycle_status: "open",
active_run: {
task_id: "22222222-2222-4222-8222-222222222222",
run_id: "55555555-5555-4555-8555-555555555555",
generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
agent_session_id: null,
status: "queued",
lease_expires_at: null,
heartbeat_at: null,
started_at: null,
finished_at: null,
failure_code: null,
failure_detail: null,
dispatch_attempts: 0,
next_dispatch_at: "2026-09-23T08:00:00Z",
revision: 1,
created_at: "2026-09-23T08:00:00Z",
updated_at: "2026-09-23T08:00:00Z"
},
status: "queued",
priority: "normal",
revision: 2,
execution_generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
title: "预约维修",
summary: "任务当前说明",
source: { kind: "external_system", external_id: "work-order-1", external_revision: 2, event_kind: "general" },
trigger: { kind: "external_sync", occurred_at: "2026-09-23T08:00:00Z", detail: "初次同步时的旧描述" },
scheduled_for: "2026-09-23T10:00:00Z",
agent_session_id: null,
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: null,
created_at: "2026-09-23T08:00:00Z",
updated_at: "2026-09-23T09:00:00Z",
latest_plan: null,
latest_report: null,
evidence: [],
actions: [],
permissions: {
can_publish_artifacts: false,
can_propose_actions: false,
can_sync_external_feedback: false,
can_decide_work_orders: false,
can_execute_work_orders: false,
can_decide_commands: false,
can_execute_commands: false
},
work_order_snapshot: {
work_order_id: "44444444-4444-4444-8444-444444444444",
revision: 2,
title: "预约维修",
summary: "编辑后的工单说明",
status: "open",
priority: "normal",
assignee: "维修班组",
scheduled_at: "2026-09-23T10:00:00Z",
work_order_type: "general",
location: "测试点",
scenario_code: null,
subtasks: null,
origin: "synthetic",
updated_at: "2026-09-23T09:00:00Z"
}
};
}
describe("operational task condition mapper", () => {
it("uses the latest work-order snapshot instead of the immutable trigger description", () => {
expect(toOperationalTaskCondition(task("external_work_order")).detail).toBe("编辑后的工单说明");
});
it("does not render an open external work order without an Agent run as running", () => {
expect(toOperationalTaskCondition({
...task("external_work_order"),
active_run: null
}).status).toBe("pending");
});
it("falls back to the current task summary when an external work order has no snapshot", () => {
expect(toOperationalTaskCondition({
...task("external_work_order"),
work_order_snapshot: null
}).detail).toBe("任务当前说明");
});
it("keeps the event trigger detail for investigations", () => {
expect(toOperationalTaskCondition(task("event_investigation")).detail).toBe("初次同步时的旧描述");
});
});
@@ -0,0 +1,294 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
acknowledgeOperationalTaskNotification,
createOperationalRequestId,
createOperationalTask,
decideOperationalTaskAction,
executeOperationalTaskAction,
fetchOperationalTask,
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskNotifications,
reconcileOperationalTaskAction,
type OperationalTask,
type OperationalTaskDecision,
type OperationalTaskNotification
} from "../api/operational-task-client";
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
import type { WorkbenchSyncState } from "./use-workbench-sync";
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
export type CreateInvestigationInput = {
kind: ManualInvestigationKind;
title: string;
summary: string;
detail: string;
occurredAt: string;
priority?: OperationalTask["priority"];
source?: OperationalTask["source"];
triggerKind?: "event" | "manual";
};
export function useOperationalTasks(
getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState
) {
const syncEvents = sync.events;
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
const [tasks, setTasks] = useState<OperationalTask[]>([]);
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
const [available, setAvailable] = useState(false);
const activatedRef = useRef(false);
const tasksRef = useRef<OperationalTask[]>([]);
const [error, setError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const forceRefreshRef = useRef(false);
const refresh = useCallback(() => {
forceRefreshRef.current = true;
setRefreshGeneration((current) => current + 1);
}, []);
useEffect(() => {
const controller = new AbortController();
const load = async () => {
const summaries = sync.snapshot?.tasks ?? [];
try {
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
const changed = forceRefreshRef.current
? summaries
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
const details = await Promise.all(changed.map((task) =>
fetchOperationalTask(task.task_id, getAccessToken, controller.signal)
));
if (controller.signal.aborted) return;
const detailMap = new Map(details.map((task) => [task.task_id, task]));
const nextTasks = summaries.flatMap((summary) => {
const detail = detailMap.get(summary.task_id) ?? current.get(summary.task_id);
return detail ? [detail] : [];
});
tasksRef.current = nextTasks;
forceRefreshRef.current = false;
setTasks(nextTasks);
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
setAvailable(true);
activatedRef.current = true;
setError(null);
setLastSyncedAt(new Date().toISOString());
} catch (nextError) {
if (controller.signal.aborted) return;
if (!activatedRef.current) setAvailable(false);
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
if (sync.snapshot) void load();
return () => {
controller.abort();
};
}, [getAccessToken, refreshGeneration, sync.snapshot]);
useEffect(() => {
const events = syncEvents;
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);
}
void Promise.all([
notificationsChanged
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null),
Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal)
))
]).then(([nextNotifications, changedTasks]) => {
if (controller.signal.aborted) return;
if (nextNotifications) setNotifications(nextNotifications);
if (changedTasks.length > 0) {
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
tasksRef.current = [
...changedTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id))
];
setTasks(tasksRef.current);
setLastSyncedAt(new Date().toISOString());
}
acknowledgeEventsThrough(lastSequence);
}).catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
}
});
return () => controller.abort();
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
const task = await createOperationalTask({
request_id: createOperationalRequestId(),
task_type: "event_investigation",
workflow: input.kind,
title: input.title,
summary: input.summary,
priority: input.priority ?? (input.kind === "burst_report" ? "urgent" : "normal"),
source: input.source ?? { kind: "operator", external_id: null, external_revision: null, event_kind: input.kind },
trigger: {
kind: input.triggerKind ?? "manual",
occurred_at: input.occurredAt,
detail: input.detail
}
}, getAccessToken);
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
setTasks(tasksRef.current);
activatedRef.current = true;
setAvailable(true);
setLoading(false);
setError(null);
setLastSyncedAt(new Date().toISOString());
return task;
}, [getAccessToken]);
const decideAction = useCallback(async (
taskId: string,
actionId: string,
expectedVersion: number,
decision: OperationalTaskDecision,
comment?: string
) => {
const action = await decideOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion,
decision,
comment
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const executeAction = useCallback(async (
taskId: string,
actionId: string,
expectedVersion: number
) => {
const action = await executeOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const reconcileAction = useCallback(async (taskId: string, actionId: string, expectedVersion: number) => {
const action = await reconcileOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
await acknowledgeOperationalTaskNotification(
notificationId,
version,
createOperationalRequestId(),
getAccessToken
);
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
}, [getAccessToken]);
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
return {
available,
error: error ?? sync.error,
syncStatus: sync.status,
lastSyncedAt,
loading,
tasks,
conditions,
notifications,
refresh,
createInvestigation,
decideAction,
executeAction,
reconcileAction,
acknowledgeNotification,
loadEvidenceSnapshot
};
}
export function toOperationalTaskCondition(task: OperationalTask): ScheduledConditionRecord {
return {
id: operationalTaskConditionId(task.task_id),
kind: "condition",
taskId: "operational-task",
dataSource: "operational_tasks",
sessionId: task.active_run?.agent_session_id ?? task.agent_session_id ?? "",
title: task.title,
summary: task.summary,
status: toConditionStatus(task),
riskLevel: toRiskLevel(task),
scheduledAt: task.scheduled_for ?? task.trigger.occurred_at,
updatedAt: Date.parse(task.updated_at),
detail: task.task_type === "external_work_order"
? task.work_order_snapshot?.summary ?? task.summary
: task.trigger.detail,
recommendation: task.latest_report?.report.current_conclusion || undefined,
evidence: task.evidence.map((item) => item.summary),
modelName: "TJWater operational task",
operationalTask: task
};
}
export function operationalTaskConditionId(taskId: string) {
return `operational-task:${taskId}`;
}
function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
const status = task.lifecycle_status;
if (status === "completed") return "completed";
if (status === "failed") return "error";
if (status === "cancelled" || status === "waiting_human") return "warning";
if (task.task_type === "external_work_order") {
const externalStatus = task.work_order_snapshot?.status;
if (externalStatus === "completed") return "completed";
if (externalStatus === "cancelled" || externalStatus === "awaiting_reply") return "warning";
if (externalStatus === "in_progress") return "running";
return "pending";
}
return task.active_run?.status === "queued" ? "pending" : "running";
}
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
const { priority, lifecycle_status: status } = task;
if (status === "failed" || priority === "urgent") return "critical";
if (status === "waiting_human") return "attention";
return "normal";
}
@@ -27,6 +27,7 @@ import {
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
@@ -87,6 +88,7 @@ export function useWorkbenchAgent({
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -546,6 +548,7 @@ export function useWorkbenchAgent({
}
applySessionId(loadedSession.sessionId);
setSessionPurpose(loadedSession.purpose);
setSessionTitle(loadedSession.title);
setStatusLabel("Agent 历史会话已打开");
setPermissionOverrides({});
@@ -569,6 +572,20 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
}
const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId);
const collaborationSessionId = collaboration.session_id || collaboration.id;
if (!collaborationSessionId) {
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
return collaborationSessionId;
}, [loadHistorySession]);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
const normalizedTitle = title.trim();
@@ -614,6 +631,7 @@ export function useWorkbenchAgent({
(nextStatusLabel = "Agent 新对话已准备") => {
clearSessionId();
setSessionTitle("新对话");
setSessionPurpose("ordinary_chat");
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
setPermissionOverrides({});
@@ -875,6 +893,7 @@ export function useWorkbenchAgent({
}, []);
return {
apiClient: clientRef.current,
approvalMode,
collapsePanel,
expandPanel,
@@ -891,11 +910,13 @@ export function useWorkbenchAgent({
refreshSessionHistory,
startNewSession,
loadHistorySession,
openTaskCollaboration,
renameHistorySession,
deleteHistorySession,
sessionHistory,
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
sessionId,
sessionPurpose,
sessionTitle,
statusLabel,
streamRenderState,
@@ -272,6 +272,7 @@ export function useWorkbenchMap({
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
antialias: true,
preserveDrawingBuffer: true,
attributionControl: false
});
@@ -281,7 +282,6 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
removeCityScene = installCityScene(map);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
@@ -328,6 +328,7 @@ export function useWorkbenchMap({
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
removeCityScene = installCityScene(map);
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);
updateSourceStatus(
@@ -109,6 +109,16 @@ describe("workbench runtime data", () => {
expect(resolved.workOrderDataSource).toBe("none");
});
it("keeps explicit demo work orders visible without treating them as 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.workOrderDataSource).toBe("none");
});
it("keeps mock mode separate from the selected backend history day", () => {
const backendAlert = {
id: "scheduled-condition-alert-schedule-run-1",
@@ -266,7 +276,7 @@ describe("workbench runtime data", () => {
expect(resolved.runtimeDataSource).toBe("mock");
});
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
it("keeps explicit demo appointments when an explicit backend day has no work orders", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "ready",
source: "none",
@@ -284,7 +294,7 @@ describe("workbench runtime data", () => {
workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend");
});
@@ -369,7 +379,7 @@ describe("workbench runtime data", () => {
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend");
});
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
fetchWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "../api/workbench-runtime-client";
import { fetchRealtimePipeFlows } from "../api/realtime-flow-client";
@@ -13,6 +12,7 @@ import {
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
import type { ScheduledConditionItem } from "../types";
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
import type { WorkbenchSyncState } from "./use-workbench-sync";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
@@ -80,6 +80,10 @@ 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)
);
@@ -103,7 +107,7 @@ export function resolveWorkbenchRuntimeData(
);
return {
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
scheduledConditions: [...fallbackConditions, ...demoWorkOrders, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions),
@@ -128,10 +132,13 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
&& candidate.getDate() === operationalDate.getDate();
}
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
export function useWorkbenchRuntimeData(
getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState
) {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
ReturnType<typeof fetchRealtimePipeFlows>
> | null>(null);
@@ -168,40 +175,15 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
return () => window.clearInterval(intervalId);
}, [backendSnapshot]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshBackendSnapshot = async () => {
try {
const snapshot = await fetchWorkbenchRuntimeSnapshot(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setBackendSnapshot(snapshot);
}
} catch {
if (!controller.signal.aborted) setBackendSnapshot(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshBackendSnapshot,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshBackendSnapshot();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshRealtimePipeFlows = async () => {
if (document.visibilityState === "hidden") {
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
return;
}
try {
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
@@ -219,9 +201,17 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
}
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
void refreshRealtimePipeFlows();
};
void refreshRealtimePipeFlows();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
controller.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchScenarioAnalysis } from "./use-workbench-scenario-analysis";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
function analysis(runId: string) {
return {
run_id: runId,
name: "历史爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
}
describe("selected workbench scenario analysis", () => {
afterEach(() => vi.unstubAllGlobals());
it("loads geometry for the selected backend run", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(analysis(runId)), { status: 200 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("backend"));
expect(result.current.projection?.runId).toBe(runId);
expect(result.current.projection?.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
unmount();
});
it("falls back independently when the selected run cannot be read", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "unavailable" }), { status: 503 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("fallback"));
expect(result.current.projection).toBeNull();
unmount();
});
});
@@ -1,71 +0,0 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchAnalysisRun,
type WorkbenchAnalysisProjection
} from "../api/workbench-runtime-client";
const SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS = 15_000;
type ScenarioAnalysisState = {
runId: string;
projection: WorkbenchAnalysisProjection | null;
failed: boolean;
};
export type WorkbenchScenarioAnalysis = {
projection: WorkbenchAnalysisProjection | null;
status: "disabled" | "loading" | "backend" | "fallback";
};
export function useWorkbenchScenarioAnalysis(
runId: string | null,
getAccessToken?: AccessTokenProvider
): WorkbenchScenarioAnalysis {
const [state, setState] = useState<ScenarioAnalysisState | null>(null);
useEffect(() => {
if (!runId) {
setState(null);
return;
}
const controller = new AbortController();
let timeoutId: number | undefined;
setState((current) => current?.runId === runId
? current
: { runId, projection: null, failed: false });
const refresh = async () => {
try {
const projection = await fetchWorkbenchAnalysisRun(
runId,
getAccessToken,
controller.signal
);
if (!controller.signal.aborted) {
setState({ runId, projection, failed: false });
}
} catch {
if (!controller.signal.aborted) {
setState({ runId, projection: null, failed: true });
}
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(refresh, SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS);
}
}
};
void refresh();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken, runId]);
if (!runId) return { projection: null, status: "disabled" };
if (!state || state.runId !== runId) return { projection: null, status: "loading" };
if (state.failed) return { projection: null, status: "fallback" };
if (state.projection) return { projection: state.projection, status: "backend" };
return { projection: null, status: "loading" };
}
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
consumeWorkbenchEvents,
fetchWorkbenchSyncSnapshot,
type WorkbenchEvent,
type WorkbenchSyncSnapshot
} from "../api/workbench-sync-client";
const RECONCILE_INTERVAL_MS = 60_000;
const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
"workbench.alert.changed",
"workbench.work_order.changed",
"analysis.run.changed"
]);
export type WorkbenchSyncState = {
snapshot: WorkbenchSyncSnapshot | null;
events: WorkbenchEvent[];
status: "connecting" | "live" | "reconnecting";
error: string | null;
acknowledgeEventsThrough: (eventSequence: number) => void;
};
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const cursorRef = useRef(0);
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
const runtimeRefreshRef = useRef<number | undefined>(undefined);
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
}, []);
useEffect(() => {
const controller = new AbortController();
let reconnectTimer: number | undefined;
let reconcileTimer: number | undefined;
let retryMs = 1_000;
let initialized = false;
const loadSnapshot = async () => {
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
if (controller.signal.aborted) return;
cursorRef.current = next.eventCursor;
setSnapshot(next);
setError(null);
initialized = true;
};
const connect = async () => {
try {
if (!initialized) await loadSnapshot();
if (controller.signal.aborted) return;
statusRef.current = "live";
setStatus("live");
retryMs = 1_000;
await consumeWorkbenchEvents({
after: cursorRef.current,
getAccessToken,
signal: controller.signal,
onReset: loadSnapshot,
onEvents: (nextEvents) => {
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
if (unseen.length === 0) return;
cursorRef.current = unseen.at(-1)!.event_seq;
setEvents((current) => [...current, ...unseen]);
if (unseen.some((event) => RUNTIME_TOPICS.has(event.topic))) {
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
runtimeRefreshRef.current = window.setTimeout(() => {
void loadSnapshot().catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
}
});
}, 250);
}
}
});
if (!controller.signal.aborted) {
statusRef.current = "reconnecting";
setStatus("reconnecting");
}
} catch (nextError) {
if (controller.signal.aborted) return;
statusRef.current = "reconnecting";
setStatus("reconnecting");
setError(nextError instanceof Error ? nextError.message : "工作台事件连接中断");
}
if (!controller.signal.aborted) {
reconnectTimer = window.setTimeout(() => { void connect(); }, retryMs);
retryMs = Math.min(retryMs * 2, 30_000);
}
};
void connect();
reconcileTimer = window.setInterval(() => {
if (statusRef.current !== "live") void loadSnapshot().catch(() => undefined);
}, RECONCILE_INTERVAL_MS);
return () => {
controller.abort();
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
if (reconcileTimer !== undefined) window.clearInterval(reconcileTimer);
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
};
}, [getAccessToken]);
return { snapshot, events, status, error, acknowledgeEventsThrough };
}
+145 -30
View File
@@ -36,15 +36,13 @@ import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
waterQualityTriggerPrompt
} from "./peripheral-sample/work-order-agent-trigger";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import {
ScheduledConditionFeed,
type ScheduleCreateRequest
} from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
@@ -70,6 +68,8 @@ import {
} from "./workspace/workspace-context";
import { WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { operationalTaskConditionId, useOperationalTasks } from "./hooks/use-operational-tasks";
import { useWorkbenchSync } from "./hooks/use-workbench-sync";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
@@ -104,6 +104,7 @@ import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledConditionTaskId,
WorkbenchAlert,
WorkbenchUser
} from "./types";
@@ -112,6 +113,17 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
const SCHEDULED_CONDITION_TASK_IDS = new Set<ScheduledConditionTaskId>([
"scada-diagnosis",
"smart-dispatch",
"pump-energy",
"network-simulation"
]);
function isScheduledConditionTaskId(value: string): value is ScheduledConditionTaskId {
return SCHEDULED_CONDITION_TASK_IDS.has(value as ScheduledConditionTaskId);
}
export function MapWorkbenchPage({
user = WORKBENCH_USER,
onLogout,
@@ -150,11 +162,15 @@ export function MapWorkbenchPage({
const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [createdMockScheduledTasks, setCreatedMockScheduledTasks] = useState<
ScheduledConditionRecord[]
>([]);
const workbenchSync = useWorkbenchSync(getAccessToken);
const {
headerDataTime,
scheduledConditions: runtimeScheduledConditions,
scheduledConditionsLoading,
workbenchAlerts,
workbenchAlerts: runtimeWorkbenchAlerts,
runtimeDataSource,
alertDataSource,
latestAnalysisArtifact,
@@ -163,12 +179,8 @@ export function MapWorkbenchPage({
flowDataSource,
operationalDate,
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken);
const [eventConditions, setEventConditions] = useState<ScheduledConditionRecord[]>([]);
const scheduledConditions = useMemo(
() => [...eventConditions, ...runtimeScheduledConditions],
[eventConditions, runtimeScheduledConditions]
);
} = useWorkbenchRuntimeData(getAccessToken, workbenchSync);
const operationalTasks = useOperationalTasks(getAccessToken, workbenchSync);
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -194,6 +206,51 @@ export function MapWorkbenchPage({
onFrontendAction: handleFrontendAction,
getAccessToken
});
const scheduledConditions = useMemo(() => [
...createdMockScheduledTasks,
...runtimeScheduledConditions,
...(operationalTasks.available
? operationalTasks.conditions.filter((condition) =>
condition.operationalTask?.task_type === "event_investigation"
|| condition.operationalTask?.task_type === "external_work_order"
)
: [])
], [createdMockScheduledTasks, operationalTasks.available, operationalTasks.conditions, runtimeScheduledConditions]);
const handleCreateMockScheduledTask = useCallback(async (input: ScheduleCreateRequest) => {
const taskId = isScheduledConditionTaskId(input.scheduleKey)
? input.scheduleKey
: "scada-diagnosis";
const condition: ScheduledConditionRecord = {
id: `local-scheduled-task-${crypto.randomUUID()}`,
kind: "condition",
taskId,
dataSource: "mock",
sessionId: `scheduled-condition-${taskId}-${Date.now()}`,
title: input.title,
summary: input.summary,
status: "pending",
riskLevel: "normal",
scheduledAt: input.nextRunAt,
updatedAt: Date.now(),
detail: `按 ${input.intervalSeconds / 60} 分钟周期执行,仅用于当前前端演示。`,
recommendation: "等待模拟执行窗口。",
modelName: "SupplyFlow 前端演示模拟器"
};
setCreatedMockScheduledTasks((current) => [condition, ...current]);
return condition;
}, []);
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const taskNotifications = operationalTasks.notifications.map((notification) => ({
id: `operational-task-notification:${notification.notification_id}`,
conditionId: operationalTaskConditionId(notification.task_id),
title: notification.title,
description: notification.description,
severity: notification.kind === "task_failed" ? "critical" as const : "warning" as const,
occurredAt: notification.created_at,
time: new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(notification.created_at))
}));
return operationalTasks.available ? [...taskNotifications, ...runtimeWorkbenchAlerts] : runtimeWorkbenchAlerts;
}, [operationalTasks.available, operationalTasks.notifications, runtimeWorkbenchAlerts]);
const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => {
@@ -301,44 +358,56 @@ export function MapWorkbenchPage({
}
async function handlePeripheralWorkOrderAgentTrigger(workOrder: PeripheralWorkOrder) {
const trigger = toWaterQualityAgentTrigger(workOrder);
if (!trigger) {
throw new Error("工单不符合水质投诉事件触发条件。");
const burstReport = workOrder.work_order_type === "burst_report";
if (workOrder.work_order_type !== "water_quality_complaint" && workOrder.work_order_type !== "burst_report") {
throw new Error("工单不符合事件调查触发条件。");
}
const investigationTitle = burstReport ? "爆管事件调查" : "水质投诉调查";
showMapNotice({
id: "water-quality-agent-trigger",
id: "peripheral-event-agent-trigger",
tone: "loading",
title: "水质投诉事件已接收",
title: `${burstReport ? "爆管上报" : "水质投诉"}已接收`,
message: "正在建立结构化工况任务。",
duration: Infinity
});
try {
const result = await agent.triggerEvent(trigger, waterQualityTriggerPrompt(workOrder));
const condition = toWaterQualityConditionTask(workOrder, result.sessionId);
setEventConditions((current) => [
condition,
...current.filter((item) => item.id !== condition.id)
]);
const task = await operationalTasks.createInvestigation({
kind: burstReport ? "burst_report" : "water_quality_complaint",
title: investigationTitle,
summary: workOrder.summary,
detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`,
occurredAt: workOrder.scheduled_at ?? workOrder.created_at,
priority: burstReport || workOrder.priority === "urgent" ? "urgent" : "normal",
source: {
kind: "external_system",
external_id: workOrder.work_order_id,
external_revision: workOrder.revision,
event_kind: burstReport ? "burst_report" : "water_quality_complaint"
},
triggerKind: "event"
});
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setSelectedConditionId(condition.id);
const conditionId = operationalTaskConditionId(task.task_id);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({
conditionId: condition.id,
conditionId,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "water-quality-agent-trigger",
id: "peripheral-event-agent-trigger",
tone: "success",
title: result.reused ? "已恢复水质工况任务" : "已建立水质工况任务",
title: `已建立${investigationTitle}`,
message: `关联模拟工单 ${workOrder.work_order_id.slice(0, 8)},可在任务中查看处理流程。`
});
} catch (error) {
showMapNotice({
id: "water-quality-agent-trigger",
id: "peripheral-event-agent-trigger",
tone: "error",
title: "工单已创建,Agent 待触发",
title: "工况任务创建失败",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
@@ -711,6 +780,11 @@ export function MapWorkbenchPage({
}
function handleSelectAlert(alert: WorkbenchAlert) {
if (alert.id.startsWith("operational-task-notification:")) {
const notificationId = alert.id.slice("operational-task-notification:".length);
const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId);
if (notification) void operationalTasks.acknowledgeNotification(notificationId, notification.version);
}
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
if (conditionId) {
const condition = scheduledConditions.find((item) => item.id === conditionId);
@@ -809,6 +883,11 @@ export function MapWorkbenchPage({
await agent.loadHistorySession(sessionId);
}
async function handleOpenTaskCollaboration(taskId: string) {
mapController.clearScadaAnalysis();
await agent.openTaskCollaboration(taskId);
}
async function handleDeleteAgentHistorySession(sessionId: string) {
const clearsActiveAnalysis = sessionId === agent.sessionId;
const deleted = await agent.deleteHistorySession(sessionId);
@@ -1003,6 +1082,7 @@ export function MapWorkbenchPage({
sessionHistory: agent.sessionHistory,
sessionHistoryLoading: agent.sessionHistoryLoading,
activeSessionId: agent.sessionId,
qaOnly: agent.sessionPurpose === "task_collaboration",
statusLabel: agent.statusLabel,
connectionStatus: agent.connectionStatus,
streaming: agent.streaming,
@@ -1086,6 +1166,23 @@ export function MapWorkbenchPage({
onLogout={onLogout}
/>
{workbenchSync.status === "reconnecting" && workbenchSync.snapshot ? (
<div
role="status"
className="absolute left-1/2 top-[3.7rem] z-[70] -translate-x-1/2 rounded-md border border-amber-300/70 bg-amber-50/95 px-3 py-1.5 text-xs font-medium text-amber-900 shadow-xs backdrop-blur"
>
正在重连,业务数据可能陈旧
</div>
) : null}
{analysisResultsPanelOpen || peripheralSamplePanelOpen ? (
<div
aria-hidden="true"
data-map-interaction-shield
className="pointer-events-auto absolute inset-x-0 bottom-0 top-14 z-[59] touch-none overscroll-none"
/>
) : null}
<AnalysisResultsPanel
open={analysisResultsPanelOpen}
onClose={() => setAnalysisResultsPanelOpen(false)}
@@ -1223,6 +1320,8 @@ export function MapWorkbenchPage({
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
backendLoading={operationalTasks.loading}
backendError={operationalTasks.error}
visible={shouldShowConditionFeed}
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
@@ -1231,6 +1330,13 @@ export function MapWorkbenchPage({
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onCreateSchedule={handleCreateMockScheduledTask}
/>
</div>
) : null}
@@ -1360,6 +1466,8 @@ export function MapWorkbenchPage({
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
backendLoading={operationalTasks.loading}
backendError={operationalTasks.error}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
@@ -1369,6 +1477,13 @@ export function MapWorkbenchPage({
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onCreateSchedule={handleCreateMockScheduledTask}
/>
)}
</MobileWorkbenchSheet>
@@ -1,6 +1,7 @@
import maplibregl, { type Map as MapLibreMap } from "maplibre-gl";
import {
CITY_LAYERS,
CITY_LIGHT,
CITY_ROOT,
cityManifestSchema,
citySources,
@@ -37,9 +38,10 @@ class CitySceneControl {
private addLayers() {
const map = this.map;
if (!map) return;
map.setLight(CITY_LIGHT);
Object.entries(citySources()).forEach(([id, source]) => map.addSource(id, source));
const firstBusinessLayer = findCitySceneInsertionLayerId(map.getStyle().layers);
CITY_LAYERS.forEach((layer) => map.addLayer(layer, firstBusinessLayer));
const firstHitLayer = findCitySceneInsertionLayerId(map.getStyle().layers);
CITY_LAYERS.forEach((layer) => map.addLayer(layer, firstHitLayer));
this.attribution = new maplibregl.AttributionControl({
compact: true,
customAttribution:
@@ -2,6 +2,7 @@ import type { LayerSpecification } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import {
CITY_LAYERS,
CITY_LIGHT,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
@@ -18,7 +19,7 @@ const manifest = {
attribution: "OSM"
};
describe("city scene geographic contract", () => {
it("inserts buildings above every basemap layer and below business layers", () => {
it("inserts buildings after business layers and before transparent hit layers", () => {
const layers = [
{ id: "background", type: "background" },
{ id: "mapbox-light", type: "raster", source: "mapbox-light" },
@@ -28,16 +29,26 @@ describe("city scene geographic contract", () => {
type: "raster",
source: "tianditu-vector-annotation"
},
{ id: "pipes", type: "line", source: "pipes", "source-layer": "pipes" }
{ id: "pipes-flow", type: "line", source: "pipes", "source-layer": "pipes" },
{ id: "pipes-hover", type: "line", source: "pipes", "source-layer": "pipes" },
{ id: "pipes-hit", type: "line", source: "pipes", "source-layer": "pipes" }
] satisfies LayerSpecification[];
expect(findCitySceneInsertionLayerId(layers)).toBe("pipes");
expect(findCitySceneInsertionLayerId(layers)).toBe("pipes-hit");
});
it("adds only the building background and leaves business layers to their existing controls", () => {
expect(Object.keys(citySources())).toEqual(["fengxian-city-buildings"]);
expect(CITY_LAYERS.map((layer) => layer.id)).toEqual(["fengxian-city-buildings"]);
});
it("uses a stable directional light for the engineering white model", () => {
expect(CITY_LIGHT).toEqual({
anchor: "map",
position: [1.5, 225, 35],
color: "#EAF2F4",
intensity: 0.42
});
});
it("accepts WGS84 scene bounds", () => {
expect(cityManifestSchema.safeParse(manifest).success).toBe(true);
});
@@ -1,14 +1,22 @@
import type { LayerSpecification, SourceSpecification } from "maplibre-gl";
import type { LayerSpecification, LightSpecification, SourceSpecification } from "maplibre-gl";
import { z } from "zod";
import { BASE_LAYER_IDS } from "./map-control-config";
import { INTERACTIVE_HIT_LAYER_IDS } from "./layers";
import { MAP_STYLE_TOKENS as colors } from "./map-colors";
export const CITY_ROOT = "/city-model";
export const CITY_PREFIX = "fengxian-city-";
export const CITY_BUILDING_LAYER_ID = CITY_PREFIX + "buildings";
export const CITY_LIGHT = {
anchor: "map",
position: [1.5, 225, 35],
color: "#EAF2F4",
intensity: 0.42
} satisfies LightSpecification;
export function findCitySceneInsertionLayerId(layers: readonly LayerSpecification[]) {
const basemapLayerIds = new Set<string>(BASE_LAYER_IDS);
return layers.find((layer) => layer.id !== "background" && !basemapLayerIds.has(layer.id))?.id;
// Layers painted after a 3D extrusion bypass depth testing, so ground overlays stay below it.
const hitLayerIds = new Set<string>(INTERACTIVE_HIT_LAYER_IDS);
return layers.find((layer) => hitLayerIds.has(layer.id))?.id;
}
export const cityManifestSchema = z
@@ -44,7 +52,7 @@ export function citySources(): Record<string, SourceSpecification> {
export const CITY_LAYERS: LayerSpecification[] = [
{
id: CITY_PREFIX + "buildings",
id: CITY_BUILDING_LAYER_ID,
source: CITY_PREFIX + "buildings",
"source-layer": "buildings",
type: "fill-extrusion",
@@ -26,6 +26,7 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
const exportHeight = Math.round(sourceHeight * exportScale);
const container = createExportContainer(exportWidth, exportHeight);
const style = cloneMapStyle(map.getStyle());
style.light = map.getLight();
const bounds = map.getBounds();
document.body.appendChild(container);
@@ -37,6 +38,7 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
antialias: true,
preserveDrawingBuffer: true,
interactive: false,
attributionControl: false,
@@ -60,6 +60,13 @@ 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" }]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ lane: "plan", actualTime: null });
});
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
@@ -151,6 +151,25 @@ export function filterOperationalEvents(
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.operationalTask?.status === "cancelled") {
return [{
id: `cancelled-${item.id}`,
title: item.title,
description: item.summary,
lane: "exception",
status: "cancelled",
importance: "normal",
plannedTime: item.operationalTask.scheduled_for,
actualTime: null,
cursorTime: item.operationalTask.updated_at,
correlationId: item.operationalTask.task_id,
scheduleId: item.operationalTask.workflow,
conditionId: item.id,
sourceLabel: item.operationalTask.task_type === "external_work_order" ? "预约任务" : "调查任务",
mapTargets: []
}];
}
if (item.status !== "warning" && item.status !== "error") {
return [];
}
@@ -196,6 +215,10 @@ function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[]
mapTargets: getWorkOrderMapTargets(item)
};
if (item.dataSource === "backend") {
return [planEvent];
}
if (item.status === "pending") {
return [planEvent];
}
@@ -13,16 +13,45 @@ vi.mock("../api/project-client", () => ({
}
}));
import { PeripheralSampleClient, type PeripheralTask } from "./client";
import {
PeripheralSampleClient,
type PeripheralCreateWorkOrder,
type PeripheralWorkOrder
} from "./client";
const task = (revision: number, feedback: PeripheralTask["feedback"] = []): PeripheralTask => ({
task_id: "10000000-0000-4000-8000-000000000001", project_id: "project-1", revision,
title: "核查", summary: "摘要", source: { system: "next", task_id: "20000000-0000-4000-8000-000000000001", object_type: "validation", object_id: "30000000-0000-4000-8000-000000000001", revision: 1 },
asset_ids: ["DEMO-P001"], analysis_window: { start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z" },
evidence_refs: [], requested_check: "核查", status: "processing", origin: "synthetic",
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", created_by: "user", history: [], feedback
const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrder => ({
work_order_id: "50000000-0000-4000-8000-000000000001",
project_id: "project-1",
revision,
title: "演示 测试工单",
summary: "外围联调",
status: "in_progress",
priority: "high",
assignee: null,
scheduled_at: null,
origin: "synthetic",
work_order_type: "general",
location: null,
scenario_code: null,
created_at: "2026-09-18T00:00:00Z",
updated_at: "2026-09-18T00:00:00Z",
created_by: "user",
updated_by: "user",
...overrides
});
const createWorkOrderInput: Omit<PeripheralCreateWorkOrder, "request_id"> = {
title: "演示 测试工单",
summary: "外围联调",
status: "in_progress",
priority: "high",
assignee: null,
scheduled_at: null,
work_order_type: "general",
location: null,
scenario_code: null
};
describe("PeripheralSampleClient", () => {
beforeEach(() => vi.restoreAllMocks());
@@ -38,37 +67,33 @@ describe("PeripheralSampleClient", () => {
it("rejects malformed and cross-project successful read responses", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ project_id: "project-1" }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...task(1), project_id: "project-2" }), { status: 200 })));
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [workOrder(1, { project_id: "project-2" })], next_cursor: null
}), { status: 200 })));
const client = new PeripheralSampleClient();
await expect(client.getDemoData()).rejects.toMatchObject({ code: "INVALID_RESPONSE" });
await expect(client.getTask(task(1).task_id)).rejects.toMatchObject({
await expect(client.listWorkOrders()).rejects.toMatchObject({
code: "INVALID_RESPONSE",
effect: "none"
});
});
it("keeps the newer task projection when a late state arrives", async () => {
it("keeps the newer scenario projection when a late state arrives", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(task(4)), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(task(3)), { status: 200 })));
.mockResolvedValueOnce(new Response(JSON.stringify({ revision: 4, scenario: "late_state" }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ revision: 3, scenario: "normal" }), { status: 200 })));
const client = new PeripheralSampleClient(async () => "token");
expect((await client.getTask(task(4).task_id)).revision).toBe(4);
expect((await client.getTask(task(4).task_id)).revision).toBe(4);
});
it("deduplicates feedback by feedback_id", async () => {
const feedback = { feedback_id: "40000000-0000-4000-8000-000000000001", task_id: task(1).task_id, recorded_by: "user", recorded_at: "2026-01-01T00:00:00Z", occurred_at: "2026-01-01T00:00:00Z", applicable_time: { start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z" }, asset_ids: ["DEMO-P001"], finding: "uncertain" as const, notes: "待复核", attachment_refs: [], origin: "synthetic" as const };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(task(2, [feedback, feedback])), { status: 200 })));
expect((await new PeripheralSampleClient().getTask(task(1).task_id)).feedback).toHaveLength(1);
expect((await client.getScenario()).revision).toBe(4);
expect((await client.getScenario()).revision).toBe(4);
});
it("queries the receipt after an unknown-effect response without repeating POST", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "RESPONSE_LOST", message: "lost", effect: "unknown", request_id: "request-1" }), { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request-1", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request-1", result: workOrder(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const result = await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source });
const result = await new PeripheralSampleClient().createWorkOrder(createWorkOrderInput);
expect(result.revision).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
@@ -78,12 +103,11 @@ describe("PeripheralSampleClient", () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new TypeError("socket closed"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "ignored", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "ignored", result: workOrder(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
const input = { ...task(1), source: task(1).source };
await expect(client.createTask(input)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
expect((await client.createTask(input)).revision).toBe(2);
await expect(client.createWorkOrder(createWorkOrderInput)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
expect((await client.createWorkOrder(createWorkOrderInput)).revision).toBe(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
expect(fetchMock.mock.calls[1]?.[0]).toEqual(fetchMock.mock.calls[2]?.[0]);
});
@@ -91,9 +115,9 @@ describe("PeripheralSampleClient", () => {
it("treats an unparsable 503 POST response as unknown and recovers by receipt", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response("gateway noise", { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", result: workOrder(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
expect((await new PeripheralSampleClient().createWorkOrder(createWorkOrderInput)).revision).toBe(2);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
});
@@ -101,16 +125,16 @@ describe("PeripheralSampleClient", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient(async () => { throw new Error("token unavailable"); });
await expect(client.createTask({ ...task(1), source: task(1).source })).rejects.toThrow("token unavailable");
await expect(client.createWorkOrder(createWorkOrderInput)).rejects.toThrow("token unavailable");
expect(fetchMock).not.toHaveBeenCalled();
});
it("routes an invalid successful POST body through receipt recovery", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", task_id: task(2).task_id, result: task(2) }), { status: 200 }));
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "request", result: workOrder(2) }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
expect((await new PeripheralSampleClient().createWorkOrder(createWorkOrderInput)).revision).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
@@ -120,8 +144,8 @@ describe("PeripheralSampleClient", () => {
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
await expect(client.createTask({ ...task(1), source: task(1).source })).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
await expect(client.createTask({ ...task(1), title: "另一项核查", source: task(1).source })).rejects.toMatchObject({ code: "PENDING_COMMAND_MISMATCH" });
await expect(client.createWorkOrder(createWorkOrderInput)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
await expect(client.createWorkOrder({ ...createWorkOrderInput, title: "另一项核查" })).rejects.toMatchObject({ code: "PENDING_COMMAND_MISMATCH" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
@@ -129,28 +153,20 @@ describe("PeripheralSampleClient", () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new TypeError("disconnected"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: "NOT_FOUND", message: "pending", effect: "none", request_id: null }), { status: 404 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "saved-request", task_id: task(2).task_id, result: task(2) }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(task(3)), { status: 200 }));
.mockResolvedValueOnce(new Response(JSON.stringify({ request_id: "saved-request", result: workOrder(2) }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(workOrder(3)), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const client = new PeripheralSampleClient();
const input = { ...task(1), source: task(1).source };
await expect(client.createTask(input)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
await expect(client.createWorkOrder(createWorkOrderInput)).rejects.toMatchObject({ code: "UNKNOWN_OUTCOME" });
const receiptUrl = String(fetchMock.mock.calls[1]?.[0]);
const requestId = receiptUrl.split("/").at(-1)!;
expect("task_id" in await client.getReceipt(requestId)).toBe(true);
expect((await client.createTask({ ...input, title: "另一项核查" })).revision).toBe(3);
expect("work_order_id" in await client.getReceipt(requestId)).toBe(true);
expect((await client.createWorkOrder({ ...createWorkOrderInput, title: "另一项核查" })).revision).toBe(3);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(2);
});
it("uses versioned PATCH and DELETE for managed test work orders", async () => {
const order = {
work_order_id: "50000000-0000-4000-8000-000000000001", project_id: "project-1", revision: 2,
title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const,
priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const,
work_order_type: "general" as const, location: null, scenario_code: null,
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
created_by: "user", updated_by: "user"
};
const order = workOrder(2);
const deleted = { resource_type: "work_order" as const, resource_id: order.work_order_id,
revision: 3, deleted: true as const };
const fetchMock = vi.fn()
@@ -160,7 +176,13 @@ describe("PeripheralSampleClient", () => {
const client = new PeripheralSampleClient();
await expect(client.updateWorkOrder(order.work_order_id, {
expected_revision: 1, status: "in_progress", clear_assignee: false, clear_scheduled_at: false
expected_revision: 1,
status: "in_progress",
clear_assignee: false,
clear_location: false,
clear_scenario_code: false,
clear_scheduled_at: false,
clear_subtasks: false
})).resolves.toEqual(order);
await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted);
@@ -5,11 +5,6 @@ import type { components } from "./generated/schema";
type Schemas = components["schemas"];
export type PeripheralContext = Schemas["Context"];
export type PeripheralTask = Schemas["Task"];
export type PeripheralTaskPage = Schemas["TaskPage"];
export type PeripheralCreateTask = Schemas["CreateTask"];
export type PeripheralTransition = Schemas["Transition"];
export type PeripheralAddFeedback = Schemas["AddFeedback"];
export type PeripheralScenarioState = Schemas["ScenarioState"];
export type PeripheralScenario = PeripheralScenarioState["scenario"];
export type PeripheralPointPage = Schemas["PointPage"];
@@ -26,7 +21,7 @@ export type PeripheralCreateTestDataRecord = Schemas["CreateTestDataRecord"];
export type PeripheralUpdateTestDataRecord = Schemas["UpdateTestDataRecord"];
export type PeripheralDeletedResource = Schemas["DeletedResource"];
type PeripheralMutationResult = PeripheralTask | PeripheralScenarioState | PeripheralWorkOrder |
type PeripheralMutationResult = PeripheralScenarioState | PeripheralWorkOrder |
PeripheralTestDataRecord | PeripheralDeletedResource;
const API_PREFIX = "/api/sample/v1";
@@ -34,7 +29,6 @@ const API_PREFIX = "/api/sample/v1";
export class PeripheralSampleClient {
private readonly baseUrl = env.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL;
private projectIdPromise: Promise<string> | null = null;
private readonly taskProjection = new Map<string, PeripheralTask>();
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
private scenarioProjection: PeripheralScenarioState | null = null;
@@ -56,17 +50,6 @@ export class PeripheralSampleClient {
return this.get<PeripheralObservations>(`/monitoring/observations?${query}`, signal);
}
async listTasks(cursor?: string | null, signal?: AbortSignal) {
const query = new URLSearchParams({ limit: "20" });
if (cursor) query.set("cursor", cursor);
const page = await this.get<PeripheralTaskPage>(`/tasks?${query}`, signal);
return { ...page, items: page.items.map((task) => this.projectTask(task)) };
}
getTask(taskId: string, signal?: AbortSignal) {
return this.getTaskProjected(`/tasks/${encodeURIComponent(taskId)}`, signal);
}
async getReceipt(requestId: string, signal?: AbortSignal) {
const receipt = await this.get<Schemas["Receipt"]>(
`/requests/${encodeURIComponent(requestId)}`, signal
@@ -84,26 +67,6 @@ export class PeripheralSampleClient {
}, signal);
}
createTask(input: Omit<PeripheralCreateTask, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>("/tasks", {
...input, request_id: crypto.randomUUID()
}, signal);
}
transition(taskId: string, input: Omit<PeripheralTransition, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>(
`/tasks/${encodeURIComponent(taskId)}/transitions`,
{ ...input, request_id: crypto.randomUUID() }, signal
);
}
addFeedback(taskId: string, input: Omit<PeripheralAddFeedback, "request_id" | "feedback_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTask>(
`/tasks/${encodeURIComponent(taskId)}/feedback`,
{ ...input, request_id: crypto.randomUUID(), feedback_id: crypto.randomUUID() }, signal
);
}
createWorkOrder(input: Omit<PeripheralCreateWorkOrder, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralWorkOrder>("/work-orders", {
...input, request_id: crypto.randomUUID()
@@ -144,10 +107,6 @@ export class PeripheralSampleClient {
);
}
private async getTaskProjected(path: string, signal?: AbortSignal) {
return this.projectTask(await this.get<PeripheralTask>(path, signal));
}
private async getScenarioProjected(path: string, signal?: AbortSignal) {
return this.projectScenario(await this.get<PeripheralScenarioState>(path, signal));
}
@@ -268,8 +227,7 @@ export class PeripheralSampleClient {
return this.projectIdPromise;
}
private projectResult(result: PeripheralMutationResult) {
if (isTask(result)) return this.projectTask(result);
private projectResult(result: unknown): PeripheralMutationResult {
if (isScenarioState(result)) return this.projectScenario(result);
if (isManagedResult(result)) return result;
throw new PeripheralSampleError("INVALID_RESPONSE", "响应缺少可识别的样板资源,提交是否生效未知", "unknown", null);
@@ -283,21 +241,6 @@ export class PeripheralSampleClient {
return next;
}
private projectTask(next: PeripheralTask) {
const current = this.taskProjection.get(next.task_id);
if (current && next.revision < current.revision) return current;
const feedback = [...new Map(next.feedback.map((item) => [item.feedback_id, item])).values()];
const projected = { ...next, feedback };
this.taskProjection.set(next.task_id, projected);
return projected;
}
}
function isTask(value: unknown): value is PeripheralTask {
return Boolean(value && typeof value === "object" &&
typeof (value as Partial<PeripheralTask>).task_id === "string" &&
typeof (value as Partial<PeripheralTask>).revision === "number" &&
Array.isArray((value as Partial<PeripheralTask>).feedback));
}
function isScenarioState(value: unknown): value is PeripheralScenarioState {
@@ -367,10 +310,6 @@ function isRecognizedReadPayload(path: string, value: unknown): boolean {
if (path.startsWith("/requests/")) {
return typeof item.request_id === "string" && "result" in item;
}
if (path.startsWith("/tasks?")) {
return isPageOf(value, isTask) && (item.next_cursor === null || typeof item.next_cursor === "string");
}
if (path.startsWith("/tasks/")) return isTask(value);
if (path.startsWith("/monitoring/points")) return isPageOf(value, isPoint);
if (path.startsWith("/monitoring/alerts")) return isPageOf(value, isAlert);
if (path.startsWith("/monitoring/observations")) {
+95 -593
View File
@@ -124,75 +124,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/sample/v1/tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Tasks */
get: operations["tasks_api_sample_v1_tasks_get"];
put?: never;
/** Submit */
post: operations["submit_api_sample_v1_tasks_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/sample/v1/tasks/{task_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Task */
get: operations["task_api_sample_v1_tasks__task_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/sample/v1/tasks/{task_id}/feedback": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Feedback */
post: operations["feedback_api_sample_v1_tasks__task_id__feedback_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/sample/v1/tasks/{task_id}/transitions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Transition */
post: operations["transition_api_sample_v1_tasks__task_id__transitions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/sample/v1/test-data": {
parameters: {
query?: never;
@@ -288,38 +219,6 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AddFeedback */
AddFeedback: {
applicable_time: components["schemas"]["TimeRange"];
/** Asset Ids */
asset_ids: string[];
/** Attachment Refs */
attachment_refs: string[];
/** Expected Revision */
expected_revision: number;
/**
* Feedback Id
* Format: uuid
*/
feedback_id: string;
/**
* Finding
* @enum {string}
*/
finding: "instrument_fault" | "no_instrument_fault" | "uncertain";
/** Notes */
notes: string;
/**
* Occurred At
* Format: date-time
*/
occurred_at: string;
/**
* Request Id
* Format: uuid
*/
request_id: string;
};
/** Alert */
Alert: {
/** Alert Id */
@@ -390,26 +289,6 @@ export interface components {
*/
user_id: string;
};
/** CreateTask */
CreateTask: {
analysis_window: components["schemas"]["TimeRange"];
/** Asset Ids */
asset_ids: string[];
/** Evidence Refs */
evidence_refs: components["schemas"]["ObjectRef"][];
/**
* Request Id
* Format: uuid
*/
request_id: string;
/** Requested Check */
requested_check: string;
source: components["schemas"]["SourceLink"];
/** Summary */
summary: string;
/** Title */
title: string;
};
/** CreateTestDataRecord */
CreateTestDataRecord: {
/** Label */
@@ -459,22 +338,28 @@ export interface components {
* Format: uuid
*/
request_id: string;
/** Scheduled At */
scheduled_at?: string | null;
/** Scenario Code */
scenario_code?: string | null;
/** Scheduled At */
scheduled_at?: string | null;
/**
* Status
* @default draft
* @enum {string}
*/
status: "draft" | "open" | "in_progress" | "completed" | "cancelled";
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary: string;
/** Title */
title: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
/**
* Work Order Type
* @default general
* @enum {string}
*/
work_order_type: "general" | "water_quality_complaint" | "burst_report";
};
/** DeleteResource */
DeleteResource: {
@@ -902,13 +787,6 @@ export interface components {
*/
updated_at: string;
};
/** TaskPage */
TaskPage: {
/** Items */
items: components["schemas"]["Task"][];
/** Next Cursor */
next_cursor: string | null;
};
/** TestDataRecord */
TestDataRecord: {
/**
@@ -983,23 +861,6 @@ export interface components {
*/
start: string;
};
/** Transition */
Transition: {
/** Expected Revision */
expected_revision: number;
/** Reason */
reason: string;
/**
* Request Id
* Format: uuid
*/
request_id: string;
/**
* Target Status
* @enum {string}
*/
target_status: "pending" | "processing" | "pending_review" | "completed";
};
/** UpdateTestDataRecord */
UpdateTestDataRecord: {
/**
@@ -1038,13 +899,30 @@ export interface components {
* @default false
*/
clear_assignee: boolean;
/**
* Clear Location
* @default false
*/
clear_location: boolean;
/**
* Clear Scenario Code
* @default false
*/
clear_scenario_code: boolean;
/**
* Clear Scheduled At
* @default false
*/
clear_scheduled_at: boolean;
/**
* Clear Subtasks
* @default false
*/
clear_subtasks: boolean;
/** Expected Revision */
expected_revision: number;
/** Location */
location?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/**
@@ -1052,10 +930,14 @@ export interface components {
* Format: uuid
*/
request_id: string;
/** Scenario Code */
scenario_code?: string | null;
/** Scheduled At */
scheduled_at?: string | null;
/** Status */
status?: ("draft" | "open" | "in_progress" | "completed" | "cancelled") | null;
status?: ("draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled") | null;
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary?: string | null;
/** Title */
@@ -1091,15 +973,17 @@ export interface components {
project_id: string;
/** Revision */
revision: number;
/** Scheduled At */
scheduled_at: string | null;
/** Scenario Code */
scenario_code?: string | null;
/** Scheduled At */
scheduled_at: string | null;
/**
* Status
* @enum {string}
*/
status: "draft" | "open" | "in_progress" | "completed" | "cancelled";
status: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
/** Subtasks */
subtasks?: components["schemas"]["WorkbenchRuntimeWorkOrderSubtask"][] | null;
/** Summary */
summary: string;
/** Title */
@@ -1116,8 +1000,12 @@ export interface components {
* Format: uuid
*/
work_order_id: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
/**
* Work Order Type
* @default general
* @enum {string}
*/
work_order_type: "general" | "water_quality_complaint" | "burst_report";
};
/** WorkOrderPage */
WorkOrderPage: {
@@ -1126,6 +1014,57 @@ 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;
@@ -1812,443 +1751,6 @@ export interface operations {
};
};
};
tasks_api_sample_v1_tasks_get: {
parameters: {
query?: {
limit?: number;
cursor?: string | null;
source_task_id?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskPage"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
};
};
submit_api_sample_v1_tasks_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateTask"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
};
};
task_api_sample_v1_tasks__task_id__get: {
parameters: {
query?: never;
header?: never;
path: {
task_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
};
};
feedback_api_sample_v1_tasks__task_id__feedback_post: {
parameters: {
query?: never;
header?: never;
path: {
task_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddFeedback"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
};
};
transition_api_sample_v1_tasks__task_id__transitions_post: {
parameters: {
query?: never;
header?: never;
path: {
task_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["Transition"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SampleError"];
};
};
};
};
test_data_api_sample_v1_test_data_get: {
parameters: {
query?: {
@@ -1,12 +1,7 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Ban,
CalendarClock,
CheckCircle2,
CircleDot,
ClipboardList,
Clock3,
Database,
LoaderCircle,
Pencil,
Plus,
@@ -16,58 +11,51 @@ import {
X
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
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 { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
PeripheralContext,
PeripheralSampleClient,
PeripheralTestDataRecord,
PeripheralWorkOrder
} from "./client";
type ResourceView = "orders" | "data";
type Inventory = { workOrders: number; testData: number };
type PeripheralSubtask = NonNullable<PeripheralWorkOrder["subtasks"]>[number];
type PeripheralStage = PeripheralSubtask["stages"][number];
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange, onAgentTrigger }: {
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
onInventoryChange?: (inventory: Inventory) => void;
refreshGeneration?: number;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
const [view, setView] = useState<ResourceView>("orders");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (capabilities.work_orders !== "available") return;
setBusy("refresh");
setError(null);
const [orderResult, dataResult] = await Promise.allSettled([
capabilities.work_orders === "available"
? client.listWorkOrders()
: Promise.resolve({ items: [], next_cursor: null }),
capabilities.test_data === "available"
? client.listTestData()
: Promise.resolve({ items: [], next_cursor: null })
]);
if (orderResult.status === "fulfilled") setOrders(orderResult.value.items);
if (dataResult.status === "fulfilled") setRecords(dataResult.value.items);
const failed = [orderResult, dataResult].find((result) => result.status === "rejected");
if (failed?.status === "rejected") setError(message(failed.reason));
try {
setOrders((await client.listWorkOrders()).items);
} catch (nextError) {
setError(message(nextError));
} finally {
setBusy(null);
}, [capabilities.test_data, capabilities.work_orders, client]);
}
}, [capabilities.work_orders, client]);
useEffect(() => { void refresh(); }, [refresh]);
useEffect(() => {
onInventoryChange?.({ workOrders: orders.length, testData: records.length });
}, [onInventoryChange, orders.length, records.length]);
void refresh();
}, [refresh, refreshGeneration]);
const mutate = async (key: string, operation: () => Promise<void>) => {
setBusy(key);
@@ -81,61 +69,44 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
}
};
return <WorkbenchSection
title="外围工单与测试数据"
description="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
return (
<WorkbenchSection
title="工单接口测试"
description="验证创建、编辑、过程状态、反馈回填与后端同步。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
actions={(
<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
</Button>
)}
>
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null}
{capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<ClipboardList size={13} />} onClick={() => setView("orders")}>工单列表</ResourceSwitch>
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
</div>
{view === "orders" ? (
capabilities.work_orders === "available"
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} onAgentTrigger={onAgentTrigger} />
: null
{capabilities.work_orders !== "available" ? (
<WorkbenchNotice tone="warning" title="测试工单存储待供给">
缺少 peripheral_sample.work_orders 或 work_order_revisions。
</WorkbenchNotice>
) : (
capabilities.test_data === "available"
? <TestData records={records} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
: null
<WorkOrders
orders={orders}
canWrite={canWrite}
busy={busy}
mutate={mutate}
client={client}
onAgentTrigger={onAgentTrigger}
/>
)}
</WorkbenchSection>;
</WorkbenchSection>
);
}
function ResourceSwitch({ active, count, icon, onClick, children }: {
active: boolean;
count: number;
icon: ReactNode;
onClick: () => void;
children: ReactNode;
}) {
return <button
type="button"
aria-pressed={active}
onClick={onClick}
className={cn(
"flex min-h-9 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-[background-color,color,box-shadow] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
active ? "bg-white text-slate-900 shadow-xs" : "text-slate-500 [@media(hover:hover)]:hover:text-slate-800"
)}
>
{icon}<span className="truncate">{children}</span><span className="tabular-nums text-slate-400">{count}</span>
</button>;
}
function StoragePending({ resource, tables }: { resource: string; tables: string }) {
return <WorkbenchNotice tone="warning" title={`${resource}存储待供给`}>
缺少 peripheral_sample.{tables.replace(" / ", " 或 peripheral_sample.")};原有监测回放和演练仍可使用。
</WorkbenchNotice>;
}
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: ResourceProps & {
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
@@ -144,10 +115,17 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [summary, setSummary] = useState("");
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{
message: string;
workOrder: PeripheralWorkOrder;
} | null>(null);
const [assignee, setAssignee] = useState("");
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 [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
const clear = () => {
setEditing(null);
@@ -156,11 +134,19 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setSummary("");
setStatus("draft");
setPriority("normal");
setAssignee("");
setLocation("");
setScenarioCode("");
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setSubtasks(null);
};
const create = () => {
clear();
setEditorOpen(true);
setSubtasks([createDefaultSubtask()]);
};
const edit = (item: PeripheralWorkOrder) => {
setEditing(item);
setEditorOpen(true);
@@ -168,7 +154,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setSummary(item.summary);
setStatus(item.status);
setPriority(item.priority);
setAssignee(item.assignee ?? "");
setLocation(item.location ?? "");
setScenarioCode(item.scenario_code ?? "");
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setSubtasks(item.subtasks ?? null);
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
if (!onAgentTrigger) {
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
@@ -181,81 +173,244 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setAgentTriggerFailure({ message: message(error), workOrder });
}
};
const createWaterQualityComplaint = () => void mutate("create-water-quality-order", async () => {
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
const waterQuality = eventKind === "water_quality_complaint";
const workOrder = await client.createWorkOrder({
title: "居民反映自来水有异味",
summary: "演示小区多户居民反映晨间用水有异味,请核查上游水质及二次供水环节。",
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
summary: eventDescription.trim(),
status: "open",
priority: "high",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: null,
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: "WQ-SECONDARY-SUPPLY-001"
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
subtasks: null
});
setEventEditorOpen(false);
setEventLocation("");
setEventDescription("");
await triggerAgent(workOrder);
});
return <div>
const processValid = Boolean(subtasks?.length && subtasks.every(isValidSubtask));
const canSave = canWrite && !busy && title.trim() && summary.trim() && processValid;
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></div>
<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>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={createWaterQualityComplaint}>
{busy === "create-water-quality-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
水质投诉演示
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
<Sparkles size={13} />投诉与爆管上报
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}>
<Plus size={13} />新增预约工单
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
</div>
</div>
{agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
{eventEditorOpen ? (
<div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<FieldSelect label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-slate-600">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
</Button>
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
</div>
</div>
) : null}
{agentTriggerFailure ? (
<WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,调查任务待触发">
<p>{agentTriggerFailure.message}</p>
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
{busy === "retry-agent-trigger" ? <LoaderCircle className="animate-spin" /> : <RefreshCw size={13} />}重试触发
<RefreshCw size={13} />重试触发
</Button>
</WorkbenchNotice> : null}
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑工单" : "新增工单"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null, work_order_type: "general", location: null, scenario_code: null });
</WorkbenchNotice>
) : null}
{editorOpen ? (
<section aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"} className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑预约工单" : "新增预约工单"} onClose={clear} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className="mt-1 h-9" value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className="mt-1 h-9" value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className="mt-1 h-9" value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</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} />
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
const normalizedSubtasks = subtasks!.map(normalizeSubtask);
if (editing) {
await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision,
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
clear_assignee: !assignee.trim(),
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
clear_scheduled_at: !scheduledAt,
location: location.trim() || null,
clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
subtasks: normalizedSubtasks,
clear_subtasks: false
});
} else {
await client.createWorkOrder({
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
work_order_type: "general",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
subtasks: normalizedSubtasks
});
}
clear();
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建工单"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null}
<WorkOrderList
orders={orders}
})}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单与过程" : "创建预约工单"}
</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button>
</div>
</section>
) : null}
<section aria-label="工单列表" className="mt-3">
{orders.length ? (
<ol className="space-y-2">
{orders.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
onEdit={edit}
onDelete={(item) => void mutate(`delete-order-${item.work_order_id}`, async () => {
onEdit={() => edit(item)}
onDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</div>;
</li>
))}
</ol>
) : (
<WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="创建预约工单或事件工单后,可在这里核对数据库返回结构。" />
)}
</section>
</div>
);
}
function WorkOrderList({ orders, canWrite, busy, onEdit, onDelete }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
onEdit: (item: PeripheralWorkOrder) => void;
onDelete: (item: PeripheralWorkOrder) => void;
function ProcessEditor({ subtasks, disabled, onChange }: {
subtasks: PeripheralSubtask[] | null;
disabled: boolean;
onChange: (subtasks: PeripheralSubtask[] | null) => void;
}) {
return <section aria-label="工单列表" className="mt-3">
{orders.length ? <ol className="space-y-2">
{orders.map((item, index) => <li key={item.work_order_id}>
<WorkOrderCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
</li>)}
</ol> : <WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="新增工单后,可在这里查看接口返回的工单字段和状态。" />}
</section>;
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 }: {
@@ -266,174 +421,158 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
onEdit: () => void;
onDelete: () => void;
}) {
return <article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
const process = 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">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>{item.work_order_type === "water_quality_complaint" ? <StatusBadge tone="warning">水质投诉</StatusBadge> : null}</div>
<h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p>
{item.location ? <p className="mt-1 text-[10px] text-slate-400">位置:{item.location}</p> : null}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span>
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
<h6 className="mt-1 text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
</div>
<WorkOrderSteps status={item.status} title={item.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500">
<div className="flex shrink-0 gap-0.5">
<Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button>
<Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-200/80 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span>
<span className="max-w-full truncate font-mono" title={item.work_order_id}>工单号 {item.work_order_id}</span>
<span>r{item.revision}</span>
{item.assignee ? <span>处理人:{item.assignee}</span> : null}
{item.location ? <span>位置:{item.location}</span> : null}
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
<span className="font-mono">r{item.revision}</span>
</div>
</article>;
}
type WorkOrderStepStatus = "done" | "current" | "pending" | "skipped";
const WORK_ORDER_STEPS = [
{ id: "dispatch", title: "派发", description: "确认处理班组与计划窗口" },
{ id: "execution", title: "执行", description: "按工单要求处置并留痕" },
{ id: "reply", title: "复令", description: "回填结果并完成调度复核" }
] as const;
function WorkOrderSteps({ status, title }: {
status: PeripheralWorkOrder["status"];
title: string;
}) {
const statuses = workOrderStepStatuses(status);
const completed = statuses.filter((item) => item === "done").length;
return <section aria-label={`工单步骤 ${title}`} className="mt-3 border-t border-slate-200/80 pt-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[10px] font-semibold text-slate-500">工单步骤</p>
<span className="text-[10px] tabular-nums text-slate-400">{completed}/{WORK_ORDER_STEPS.length} 完成</span>
<div className="mt-3">
{process ? (
<WorkOrderSubtasksPanel condition={process} />
) : (
<WorkbenchNotice tone="neutral" title="未提供过程">这条旧工单没有子任务与阶段数据,不根据总状态推测完成情况。</WorkbenchNotice>
)}
</div>
<ol className="grid gap-1.5">
{WORK_ORDER_STEPS.map((step, index) => {
const stepStatus = statuses[index];
return <li key={step.id} className="grid min-w-0 grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-lg bg-white px-2 py-2 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
<WorkOrderStepIcon status={stepStatus} step={index + 1} />
<span className="min-w-0">
<span className="block text-[11px] font-semibold text-slate-800">{step.title}</span>
<span className="block truncate text-[10px] text-slate-400">{step.description}</span>
</span>
<StatusBadge tone={workOrderStepTone(stepStatus)}>{workOrderStepStatusLabel(stepStatus)}</StatusBadge>
</li>;
})}
</ol>
</section>;
}
function WorkOrderStepIcon({ status, step }: { status: WorkOrderStepStatus; step: number }) {
const className = cn(
"grid size-6 place-items-center rounded-full",
status === "done" && "bg-emerald-600 text-white",
status === "current" && "bg-cyan-50 text-cyan-700 ring-2 ring-inset ring-cyan-500",
status === "pending" && "bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200",
status === "skipped" && "bg-slate-200 text-slate-500"
</article>
);
if (status === "done") return <span className={className}><CheckCircle2 size={13} /></span>;
if (status === "current") return <span className={className}><CircleDot size={13} /></span>;
if (status === "skipped") return <span className={className}><Ban size={12} /></span>;
return <span className={className}><Clock3 size={12} /><span className="sr-only">步骤 {step}</span></span>;
}
function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
records: PeripheralTestDataRecord[];
}) {
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PeripheralTestDataRecord | null>(null);
const [label, setLabel] = useState("");
const [pointId, setPointId] = useState("DEMO-P001");
const [observedAt, setObservedAt] = useState(toLocalInput(new Date()));
const [value, setValue] = useState("");
const [unit, setUnit] = useState("m");
const [quality, setQuality] = useState<PeripheralTestDataRecord["quality"]>("valid");
const clear = () => {
setEditing(null);
setEditorOpen(false);
setLabel("");
setPointId("DEMO-P001");
setObservedAt(toLocalInput(new Date()));
setValue("");
setUnit("m");
setQuality("valid");
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: ["回填现场处理结果"]
};
const create = () => {
clear();
setEditorOpen(true);
};
const edit = (item: PeripheralTestDataRecord) => {
setEditing(item);
setEditorOpen(true);
setLabel(item.label);
setPointId(item.point_id);
setObservedAt(toLocalInput(new Date(item.observed_at)));
setValue(item.value === null ? "" : String(item.value));
setUnit(item.unit);
setQuality(item.quality);
};
return <div>
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
<div className="mt-2"><DateTimeField label="观测时间" value={observedAt} onChange={setObservedAt} disabled={!canWrite || Boolean(busy)} /></div>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">数值<Input className="mt-1 h-9" inputMode="decimal" value={value} onChange={(event) => setValue(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><FieldSelect label="质量" value={quality} onValueChange={(next) => setQuality(next as typeof quality)} disabled={!canWrite || Boolean(busy)} options={[{ value: "valid", label: "有效" }, { value: "unknown", label: "未知" }, { value: "missing", label: "缺失" }]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !label.trim() || !pointId.trim() || !unit.trim() || (value !== "" && !Number.isFinite(Number(value)))} onClick={() => void mutate("save-data", async () => {
const base = { label: label.trim(), point_id: pointId.trim(), observed_at: new Date(observedAt).toISOString(), value: value === "" ? null : Number(value), unit: unit.trim(), quality, notes: "外围样板手动测试数据" };
if (editing) await client.updateTestData(editing.record_id, { expected_revision: editing.revision, clear_value: false, ...base });
else await client.createTestData(base);
clear();
})}>{busy === "save-data" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建数据"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null}
<div className="mt-3 space-y-2">
{records.length ? records.map((item) => <article key={item.record_id} className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200"><div className="flex items-start gap-2"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-1.5"><p className="truncate text-xs font-semibold text-slate-800">{item.label}</p><StatusBadge tone={qualityTone(item.quality)}>{qualityLabel(item.quality)}</StatusBadge></div><p className="mt-1 text-[11px] text-slate-500"><span className="font-medium text-slate-700">{item.point_id}</span> · <span className="tabular-nums">{item.value ?? "缺失"} {item.unit}</span> · r{item.revision}</p><p className="mt-1 text-[10px] text-slate-400">{formatTimestamp(item.observed_at)}</p></div><div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => edit(item)}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除数据 ${item.label}`} disabled={!canWrite || Boolean(busy)} onClick={() => void mutate(`delete-data-${item.record_id}`, async () => { await client.deleteTestData(item.record_id, item.revision); if (editing?.record_id === item.record_id) clear(); })}><Trash2 size={14} /></Button></div></div></article>) : <WorkbenchEmptyState icon={<Database size={15} />} title="暂无测试数据" description="新增记录后,可验证测点读数、缺失值和质量状态。" />}
</div>
</div>;
}
function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAdd }: {
title: string;
description: string;
actionLabel: string;
canWrite: boolean;
busy: string | null;
onAdd: () => void;
}) {
return <div className="flex items-start gap-3">
<div className="min-w-0 flex-1"><h4 className="text-xs font-semibold text-slate-800">{title}</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={onAdd}><Plus size={13} />{actionLabel}</Button>
</div>;
function normalizeSubtask(subtask: PeripheralSubtask): PeripheralSubtask {
return {
...subtask,
id: subtask.id.trim(),
title: subtask.title.trim(),
summary: subtask.summary.trim(),
location: subtask.location.trim(),
assignee: subtask.assignee.trim(),
stages: subtask.stages.map((stage, index) => ({
...stage,
id: stage.id.trim(),
sequence: index + 1,
title: stage.title.trim(),
owner: stage.owner.trim(),
time_label: stage.time_label.trim(),
description: stage.description.trim()
})),
reply_requirements: subtask.reply_requirements.map((item) => item.trim()).filter(Boolean)
};
}
function isValidSubtask(subtask: PeripheralSubtask) {
return Boolean(
subtask.id.trim()
&& subtask.title.trim()
&& subtask.summary.trim()
&& subtask.location.trim()
&& subtask.assignee.trim()
&& subtask.duration_minutes > 0
&& subtask.reply_window_minutes >= 0
&& !Number.isNaN(Date.parse(subtask.scheduled_at))
&& subtask.stages.length
&& subtask.stages.every((stage) => stage.id.trim() && stage.title.trim() && stage.owner.trim() && stage.time_label.trim() && stage.description.trim())
);
}
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem | null {
if (!item.subtasks?.length) return null;
return {
id: item.work_order_id,
kind: "work_order",
code: item.scenario_code ?? item.work_order_id,
scheduledAt: item.scheduled_at ?? item.updated_at,
title: item.title,
summary: item.summary,
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
}))
};
}
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
}
type ResourceProps = {
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
};
const WORK_ORDER_STATUS_OPTIONS = [
{ value: "draft", label: "草稿" },
{ value: "open", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "awaiting_reply", label: "待复令" },
{ value: "completed", label: "已完成" },
{ 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; return new Date(value.getTime() - offset).toISOString().slice(0, 16); }
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC").slice(0, 19); }
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
function workOrderStepStatuses(status: PeripheralWorkOrder["status"]): [WorkOrderStepStatus, WorkOrderStepStatus, WorkOrderStepStatus] {
if (status === "completed") return ["done", "done", "done"];
if (status === "in_progress") return ["done", "current", "pending"];
if (status === "open") return ["current", "pending", "pending"];
if (status === "cancelled") return ["skipped", "skipped", "skipped"];
return ["pending", "pending", "pending"];
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function workOrderStepTone(status: WorkOrderStepStatus): StatusTone { return ({ done: "success", current: "info", pending: "neutral", skipped: "neutral" } satisfies Record<WorkOrderStepStatus, StatusTone>)[status]; }
function workOrderStepStatusLabel(status: WorkOrderStepStatus) { return ({ done: "已完成", current: "当前步骤", pending: "待开始", skipped: "已跳过" } satisfies Record<WorkOrderStepStatus, string>)[status]; }
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function message(error: unknown) { return error instanceof Error ? error.message : "外围工单请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; }
function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约/维修", water_quality_complaint: "水质投诉", burst_report: "爆管上报" } satisfies Record<typeof value, string>)[value]; }
@@ -1,28 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CircleGauge, Database, History, LoaderCircle, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import { ClipboardList, ReceiptText, RefreshCw, X } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { Button } from "@/shared/ui/button";
import { FieldSelect } from "@/shared/ui/field-select";
import { Disclosure } from "@/shared/ui/disclosure";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { cn } from "@/shared/ui/cn";
import { StatusBadge } from "@/shared/ui/status";
import { env } from "@/shared/config/env";
import { SyntheticDemoSection } from "./synthetic-demo-section";
import { Disclosure } from "@/shared/ui/disclosure";
import { ManagedTestResources } from "./managed-test-resources";
import { configuredHistoryWindow, type HistoryTimeMode } from "./history-window";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import {
PeripheralSampleClient,
PeripheralSampleError,
type PeripheralContext,
type PeripheralObservations,
type PeripheralPointPage,
type PeripheralScenario,
type PeripheralWorkOrder
} from "./client";
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
open: boolean;
onClose: () => void;
@@ -31,127 +22,109 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
const initialHistory = useRef(configuredHistoryWindow(env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE));
const [context, setContext] = useState<PeripheralContext | null>(null);
const [scenario, setScenario] = useState<PeripheralScenario>("normal");
const [monitoring, setMonitoring] = useState("未读取");
const [points, setPoints] = useState<PeripheralPointPage["items"]>([]);
const [pointId, setPointId] = useState("");
const [historyMode, setHistoryMode] = useState<HistoryTimeMode>(
env.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE
);
const [historyStart, setHistoryStart] = useState(initialHistory.current.start);
const [historyEnd, setHistoryEnd] = useState(initialHistory.current.end);
const [observations, setObservations] = useState<PeripheralObservations | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [receiptRequestId, setReceiptRequestId] = useState("");
const [inventory, setInventory] = useState({ workOrders: 0, testData: 0 });
const [refreshGeneration, setRefreshGeneration] = useState(0);
const refresh = useCallback(async () => {
if (!client.configured) return;
setBusy("refresh"); setError(null);
const [contextResult, scenarioResult, pointResult, alertResult] = await Promise.allSettled([
client.getContext(), client.getScenario(), client.listPoints(), client.listAlerts()
]);
if (contextResult.status === "fulfilled") setContext(contextResult.value);
if (scenarioResult.status === "fulfilled") setScenario(scenarioResult.value.scenario);
if (pointResult.status === "fulfilled") {
setPoints(pointResult.value.items);
setPointId((current) => current || pointResult.value.items[0]?.point_id || "");
setBusy(true);
setError(null);
try {
setContext(await client.getContext());
setRefreshGeneration((current) => current + 1);
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setBusy(false);
}
if (pointResult.status === "fulfilled" && alertResult.status === "fulfilled") {
const alertText = alertResult.value.availability === "not_configured"
? "告警来源未配置"
: `${alertResult.value.items.length} 条告警`;
setMonitoring(`${pointResult.value.items.length} 个测点 · ${alertText} · ${originLabel(pointResult.value.origin)}`);
} else {
setMonitoring("监测来源当前不可用,可切回正常场景后刷新");
}
const firstFailure = [contextResult, scenarioResult].find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") setError(errorMessage(firstFailure.reason));
setBusy(null);
}, [client]);
useEffect(() => { if (open) void refresh(); }, [open, refresh]);
useEffect(() => {
if (open) void refresh();
}, [open, refresh]);
if (!open) return null;
return (
<aside aria-label="外围样板管理" className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(820px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]">
<aside
aria-label="外围工单测试"
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(680px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]"
>
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4 sm:px-5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white"><CircleGauge size={17} /></span>
<div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">外围样板管理</h2><p className="text-xs text-slate-500">演练上下文、监测回放与预约工单投影</p></div>
<Button size="icon" variant="ghost" aria-label="刷新外围样板" onClick={() => void refresh()} disabled={Boolean(busy)}><RefreshCw size={16} className={cn(busy === "refresh" && "animate-spin")} /></Button>
<Button size="icon" variant="ghost" aria-label="关闭外围样板" onClick={onClose}><X size={17} /></Button>
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white">
<ClipboardList size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围工单测试</h2>
<p className="text-xs text-slate-500">创建、更新过程、回执核对与事件上报</p>
</div>
<Button size="icon" variant="ghost" aria-label="刷新外围工单" onClick={() => void refresh()} disabled={busy}>
<RefreshCw size={16} className={cn(busy && "animate-spin")} />
</Button>
<Button size="icon" variant="ghost" aria-label="关闭外围工单测试" onClick={onClose}>
<X size={17} />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
<div className="mb-3 grid gap-2 sm:grid-cols-2">
<WorkbenchNotice tone="info" title="合成演练环境">{DISCLAIMER}</WorkbenchNotice>
{!client.configured ? <WorkbenchNotice tone="warning">未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接任何外围服务。</WorkbenchNotice> : null}
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{client.configured && !context ? <WorkbenchNotice role="status" tone="neutral">正在核验项目授权与样板服务上下文。</WorkbenchNotice> : null}
</div>
<SampleOverview
configured={client.configured}
context={context}
points={points.length}
scenario={scenario}
resources={inventory.workOrders + inventory.testData}
/>
{context ? (
<div className="mt-3 grid items-start gap-3 md:grid-cols-[minmax(250px,0.78fr)_minmax(390px,1.22fr)]">
<div className="min-w-0 space-y-3">
<WorkbenchSection title="连接与演练场景" description={monitoring}
icon={<RadioTower size={15} />} tone="teal" meta={<StatusBadge tone={context.can_write ? "info" : "neutral"}>{context.can_write ? "可写样板" : "只读"}</StatusBadge>}>
<FieldSelect label="场景" value={scenario} disabled={!context.can_write || Boolean(busy)} options={context.scenarios.map((item) => ({ value: item, label: scenarioLabel(item) }))} onValueChange={async (value) => {
const next = value as PeripheralScenario;
setBusy("scenario"); setError(null);
try { setScenario((await client.setScenario(next)).scenario); await refresh(); }
catch (nextError) { setError(errorMessage(nextError)); }
finally { setBusy(null); }
}} />
</WorkbenchSection>
<WorkbenchSection title="历史监测回放"
description={`UTC 历史窗口 · ${historyMode === "demo" ? "固定演示时段" : historyMode === "recent" ? `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` : "自定义时段"}。仅查询外围数据。`}
icon={<History size={15} />} tone="teal">
<FieldSelect label="时间模式" value={historyMode} options={[
{ value: "demo", label: "演示历史窗口" },
{ value: "recent", label: `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` },
{ value: "custom", label: "自定义窗口" }
]} onValueChange={(value) => {
const next = value as HistoryTimeMode;
setHistoryMode(next); setObservations(null);
if (next !== "custom") {
const window = configuredHistoryWindow(next);
setHistoryStart(window.start); setHistoryEnd(window.end);
}
}} />
<FieldSelect className="mt-3" label="测点" value={pointId} placeholder="选择测点" options={points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} onValueChange={(value) => { setPointId(value); setObservations(null); }} />
<div className="mt-2 grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1">
<DateTimeField label="开始(UTC)" value={historyStart} onChange={(value) => { setHistoryStart(value); setHistoryMode("custom"); setObservations(null); }} />
<DateTimeField label="结束(UTC)" value={historyEnd} onChange={(value) => { setHistoryEnd(value); setHistoryMode("custom"); setObservations(null); }} />
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
<div className="mb-3 grid gap-2">
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
{!client.configured ? (
<WorkbenchNotice tone="warning">
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
</WorkbenchNotice>
) : null}
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{client.configured && !context ? (
<WorkbenchNotice role="status" tone="neutral">正在核验项目授权与工单接口。</WorkbenchNotice>
) : null}
</div>
<Button variant="outline" className="mt-2 w-full" disabled={!pointId || Boolean(busy)} onClick={async () => {
setBusy("observations"); setError(null);
try { setObservations(await client.listObservations(pointId, `${historyStart}:00Z`, `${historyEnd}:00Z`)); }
catch (nextError) { setError(errorMessage(nextError)); }
finally { setBusy(null); }
}}>{busy === "observations" ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}读取历史读数</Button>
{observations ? <ObservationTable observations={observations} /> : null}
</WorkbenchSection>
</div>
<div className="min-w-0 space-y-3">
{context ? (
<div className="space-y-3">
<ManagedTestResources
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
onInventoryChange={setInventory}
refreshGeneration={refreshGeneration}
onAgentTrigger={onAgentTrigger}
/>
<SyntheticDemoSection client={client} />
<Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
<Disclosure
label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>}
className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
>
<p className="pb-2 text-xs leading-5 text-slate-500">
结果未知时只查询原请求,不会重复创建或更新工单。
</p>
<div className="flex gap-2 pb-3">
<input
aria-label="提交请求号"
value={receiptRequestId}
onChange={(event) => setReceiptRequestId(event.target.value.trim())}
placeholder="request_id"
className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
/>
<Button
variant="outline"
disabled={!receiptRequestId || busy}
onClick={async () => {
setBusy(true);
setError(null);
try {
await client.getReceipt(receiptRequestId);
setError("回执已确认,请刷新工单列表查看最新状态。");
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setBusy(false);
}
}}
>查询</Button>
</div>
</Disclosure>
</div>
) : null}
</div>
@@ -159,44 +132,6 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
);
}
function SampleOverview({ configured, context, points, scenario, resources }: {
configured: boolean;
context: PeripheralContext | null;
points: number;
scenario: PeripheralScenario;
resources: number;
}) {
const items = [
{
icon: <RadioTower size={14} />,
label: "外围服务",
value: !configured ? "未配置" : context ? "已连接" : "校验中",
detail: context?.can_write ? "可维护样板" : context ? "只读访问" : ""
},
{
icon: <History size={14} />,
label: "演练场景",
value: configured ? scenarioLabel(scenario) : "无",
detail: `${points} 个监测点`
},
{
icon: <Database size={14} />,
label: "样板资源",
value: context ? `${resources} 项` : "未读取",
detail: "工单源记录与测试数据"
}
];
return <section aria-label="外围样板概览" className="grid grid-cols-3 overflow-hidden rounded-[14px] border border-slate-200/80 bg-[var(--surface-reading)] shadow-[0_2px_8px_rgba(15,23,42,0.08)]">
{items.map((item, index) => <div key={item.label} className={cn("flex min-w-0 items-center justify-center gap-3 px-2 py-3 sm:justify-start sm:px-3.5", index > 0 && "border-l border-slate-200/80")}>
<span className="hidden size-8 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-100 sm:grid">{item.icon}</span>
<div className="min-w-0 text-center sm:text-left"><p className="truncate text-[10px] font-medium text-slate-500">{item.label}</p><p className="truncate text-sm font-semibold tabular-nums text-slate-800">{item.value}</p>{item.detail ? <p className="hidden truncate text-[10px] text-slate-400 sm:block">{item.detail}</p> : null}</div>
</div>)}
</section>;
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "外围工单请求失败";
}
function ObservationTable({ observations }: { observations: PeripheralObservations }) {
return <div className="mt-3 overflow-hidden rounded-lg bg-white ring-1 ring-slate-200"><div className="flex items-center justify-between gap-2 bg-slate-50 px-2.5 py-2 text-[11px] text-slate-600"><span>{originLabel(observations.origin)} · {observations.source_id}</span><span className="tabular-nums">{observations.truncated ? "结果已截断" : `${observations.items.length} 条`}</span></div><div className="max-h-52 overflow-auto"><table className="w-full text-left text-[11px]"><thead className="sticky top-0 bg-white text-slate-500"><tr><th className="px-2.5 py-2 font-medium">时间</th><th className="px-2 py-2 text-right font-medium">读数</th><th className="px-2 py-2 font-medium">质量</th></tr></thead><tbody>{observations.items.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{item.observed_at.replace("T", " ").replace("Z", " UTC")}</td><td className="whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums">{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>{!observations.items.length ? <p className="p-3 text-center text-xs text-slate-500">此历史窗口没有读数。</p> : null}</div></div>;
}
function errorMessage(error: unknown) { return error instanceof Error ? error.message : "外围样板请求失败"; }
function originLabel(origin: "historical_replay" | "synthetic") { return origin === "historical_replay" ? "真实项目样本回放" : "合成演练"; }
function scenarioLabel(value: PeripheralScenario) { return ({ normal: "正常", missing_data: "数据缺失", unavailable: "服务不可用", response_lost: "响应丢失", duplicate_feedback: "重复反馈", conflicting_feedback: "冲突反馈", late_state: "迟到状态" } satisfies Record<PeripheralScenario, string>)[value]; }
+8 -3
View File
@@ -1,4 +1,5 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { OperationalTask } from "./api/operational-task-client";
import type { WaterNetworkSourceId } from "./map/sources";
export type DetailFeature = {
@@ -40,7 +41,8 @@ export type WorkbenchAlert = {
export type WorkbenchRuntimeDataSource =
| "mock"
| "backend"
| "scheduled_tasks";
| "scheduled_tasks"
| "operational_tasks";
export type WorkbenchAlertDataSource = "mock" | "backend";
@@ -55,7 +57,7 @@ export type WorkbenchUser = {
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply" | "completed";
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply" | "completed" | "cancelled";
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
@@ -70,7 +72,8 @@ export type ScheduledConditionTaskId =
export type ScheduledConditionRecordTaskId =
| ScheduledConditionTaskId
| "backend-schedule";
| "backend-schedule"
| "operational-task";
export type ScheduledConditionSolutionOption = {
id: string;
@@ -130,6 +133,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledCondition
analysisInsight?: ScheduledConditionAnalysisInsight;
kpis?: ScheduledConditionKpi[];
report?: ScheduledConditionReport;
operationalTask?: OperationalTask;
};
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
@@ -138,6 +142,7 @@ export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderSt
source: string;
dispatcher: string;
priority: "normal" | "urgent";
dataSource?: WorkbenchWorkOrderDataSource;
subtasks: ScheduledWorkOrderSubtask[];
evidence?: never;
modelName?: never;
@@ -13,6 +13,7 @@ const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
cancelled: "已取消",
warning: "关注",
error: "异常",
pending: "预约"
@@ -27,7 +28,7 @@ export function createConditionConversationPrompt(
: dataSource;
const backendCondition = effectiveDataSource !== "mock";
const workflow =
condition.kind === "condition" && !backendCondition
condition.kind === "condition" && !backendCondition && condition.taskId !== "water-quality-complaint"
? getRunningWorkflowDefinition(condition)
: null;
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
+16 -5
View File
@@ -200,14 +200,18 @@ test("docked Agent respects forced color mode", async ({ page }) => {
test("long history titles keep rename and explicit delete usable across viewport sizes", async ({
page
}) => {
test.setTimeout(60_000);
const sessionId = "browser-history-long-session";
const originalTitle =
"城南供水压力复核:夜间最小流量与压力变送器连续性联合调查,待现场反馈后人工审核";
let title = originalTitle;
let deleted = false;
const writes: Array<{ method: string; path: string; body: unknown }> = [];
await page.route("**/api/v1/agent/chat/sessions", (route) =>
route.fulfill({
await page.route(/\/api\/v1\/agent\/chat\/sessions(?:\?.*)?$/, (route) =>
route.request().method() === "OPTIONS"
? route.fallback()
: route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
sessions: deleted
? []
@@ -226,17 +230,24 @@ test("long history titles keep rename and explicit delete usable across viewport
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
const request = route.request();
const method = request.method();
if (method === "OPTIONS") return route.fallback();
const path = new URL(request.url()).pathname;
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
const body = request.postDataJSON();
title = body.title;
writes.push({ method, path, body });
return route.fulfill({ json: {} });
return route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {}
});
}
if (method === "DELETE" && path === `/api/v1/agent/chat/session/${sessionId}`) {
deleted = true;
writes.push({ method, path, body: null });
return route.fulfill({ json: {} });
return route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {}
});
}
return route.fallback();
});
@@ -252,7 +263,7 @@ test("long history titles keep rename and explicit delete usable across viewport
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const modelButton = panel.getByRole("button", { name: "选择 Agent 模型" });
await expect(modelButton).toBeVisible();
await expect(modelButton).toBeVisible({ timeout: 10_000 });
await expect(modelButton).toHaveAccessibleDescription("当前模型:测试模型");
await expect(modelButton).toHaveAttribute("title", "测试模型");
await waitForAnimations(panel);
+17 -2
View File
@@ -196,6 +196,18 @@ test("stream completion and the plan card remain positionally stable", async ({
);
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Origin": "*"
}
});
return;
}
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/stream")) {
@@ -219,7 +231,10 @@ test("stream completion and the plan card remain positionally stable", async ({
? { sessions: [] }
: {};
await route.fulfill({ json: body });
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: body
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -227,7 +242,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
).toContainText("测试模型");
).toContainText("测试模型", { timeout: 10_000 });
const prompt = page
.getByPlaceholder(/输入调度问题/)
.filter({ visible: true })
+8 -8
View File
@@ -9,23 +9,23 @@ test("desktop controls share focus, hover, press and open states", async ({ page
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const scenario = page.getByRole("button", { name: "切换模拟方案" });
await expect(scenario).toBeVisible();
await expect.poll(async () => (await scenario.boundingBox())?.height).toBe(32);
const conditionFeed = page.getByRole("button", { name: "工况任务" });
await expect(conditionFeed).toBeVisible();
await expect.poll(async () => (await conditionFeed.boundingBox())?.height).toBe(32);
const compactHitArea = await scenario.evaluate(
const compactHitArea = await conditionFeed.evaluate(
(element) => getComputedStyle(element, "::after").inset
);
expect(compactHitArea).toBe("-4px");
await scenario.focus();
await conditionFeed.focus();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).boxShadow))
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await scenario.hover();
await conditionFeed.hover();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).backgroundColor))
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)");
const pressTarget = page.getByRole("button", { name: /任务浮条/ });
+168 -6
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const EMPTY_RASTER_TILE = Buffer.from(
@@ -6,13 +7,71 @@ const EMPTY_RASTER_TILE = Buffer.from(
"base64"
);
type FrameTimeSummary = {
frames: number;
median_frame_ms: number;
p95_frame_ms: number;
slow_frame_ratio: number;
};
async function measureFramePerformance(page: Page, bearing?: number): Promise<FrameTimeSummary> {
const frameTimes = await page.evaluate(
({ targetBearing }) =>
new Promise<number[]>((resolve) => {
const samples: number[] = [];
const startedAt = performance.now();
let previousFrame = startedAt;
const sample = (now: number) => {
samples.push(now - previousFrame);
previousFrame = now;
if (now - startedAt >= 1_800) {
resolve(samples.slice(1));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
if (targetBearing !== null) {
window.__waterNetworkMap!.easeTo({ bearing: targetBearing, duration: 1_400 });
}
}),
{ targetBearing: bearing ?? null }
);
const sorted = [...frameTimes].sort((left, right) => left - right);
const percentile = (value: number) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * value))];
return {
frames: sorted.length,
median_frame_ms: percentile(0.5),
p95_frame_ms: percentile(0.95),
slow_frame_ratio: sorted.filter((frameTime) => frameTime > 33.4).length / sorted.length
};
}
async function waitForMapIdle(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
const map = window.__waterNetworkMap!;
if (map.loaded()) {
resolve();
return;
}
const timeout = window.setTimeout(resolve, 5_000);
map.once("idle", () => {
window.clearTimeout(timeout);
resolve();
});
})
);
}
// Uses generated local building data, while unrelated backend services are isolated.
test("3D adds opaque buildings without enabling duplicate GIS layers", async ({ page }) => {
test.skip(
process.env.TJWATER_CITY_SCENE_TEST !== "1",
"Requires the generated local city-model scene"
);
test.setTimeout(60000);
test.setTimeout(90000);
await mockWorkbenchContext(page, undefined, undefined, {}, [], {
tiandituToken: "city-scene-tianditu-token"
});
@@ -61,6 +120,46 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
expect(buildingPoint).not.toBeNull();
const mapCanvasBounds = await page.locator(".maplibregl-canvas").boundingBox();
expect(mapCanvasBounds).not.toBeNull();
await page.evaluate(({ x, y }) => {
const map = window.__waterNetworkMap!;
const location = map.unproject([x, y]);
const source = map.getSource("scada") as { setData: (data: unknown) => void };
source.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [location.lng, location.lat] },
properties: {
device_id: "OCCLUDED-SCADA-001",
device_type: "pressure",
name: "建筑后方测试点",
status: "online",
value: 0.31,
unit: "MPa"
}
}
]
});
}, buildingPoint!);
await expect
.poll(() =>
page.evaluate(
({ x, y }) =>
window.__waterNetworkMap!.queryRenderedFeatures([x, y], {
layers: ["scada-pressure-hit"]
}).length,
buildingPoint!
)
)
.toBeGreaterThan(0);
await page.mouse.move(
mapCanvasBounds!.x + buildingPoint!.x,
mapCanvasBounds!.y + buildingPoint!.y
);
await expect
.poll(() => page.locator(".maplibregl-canvas").evaluate((canvas) => canvas.style.cursor))
.toBe("");
await page.mouse.click(
mapCanvasBounds!.x + buildingPoint!.x,
mapCanvasBounds!.y + buildingPoint!.y
@@ -79,16 +178,29 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
"fengxian-city-buildings",
"fill-extrusion-opacity"
),
light: window.__waterNetworkMap!.getLight(),
antialias: window
.__waterNetworkMap!
.getCanvas()
.getContext("webgl2")
?.getContextAttributes()?.antialias,
layerOrder: window.__waterNetworkMap!.getStyle().layers.map((layer) => layer.id)
}));
expect(scene.layers).toEqual(["fengxian-city-buildings"]);
expect(scene.sources).toEqual(["fengxian-city-buildings"]);
expect(scene.opacity).toBe(1);
expect(scene.light).toMatchObject({
anchor: "map",
position: [1.5, 225, 35],
color: "#EAF2F4",
intensity: 0.42
});
expect(scene.antialias).toBe(true);
const buildingIndex = scene.layerOrder.indexOf("fengxian-city-buildings");
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("tianditu-vector-annotation"));
expect(buildingIndex).toBeLessThan(
scene.layerOrder.findIndex((layerId) => layerId.startsWith("pipes-"))
);
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("pipes-flow"));
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("pipes-hover"));
expect(buildingIndex).toBeLessThan(scene.layerOrder.indexOf("pipes-hit"));
for (const layer of businessLayers) {
expect(
await page.evaluate(
@@ -97,10 +209,24 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
)
).toBe(layer.visibility);
}
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(90, { duration: 0 }));
await waitForMapIdle(page);
const desktopStationaryPerformance = await measureFramePerformance(page);
const desktopRotationPerformance = await measureFramePerformance(page, 90);
expect(desktopStationaryPerformance.frames).toBeGreaterThan(5);
expect(desktopRotationPerformance.frames).toBeGreaterThan(5);
await expect(page.locator("[data-map-compass]")).toHaveAttribute("data-bearing", "90.0");
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(0, { duration: 0 }));
await page.screenshot({ path: "../city-model-fengxian/reports/browser-nanqiao.png" });
const downloadPromise = page.waitForEvent("download");
const moreButton = page.getByRole("button", { name: "更多:更多地图操作", exact: true });
await moreButton.click();
await page.getByRole("button", { name: /^当前分辨率 / }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^supply-network-view-1\.0x-/);
await download.saveAs("../city-model-fengxian/reports/browser-export.png");
await expect(page.getByText(/已导出 \d+ × \d+ PNG。/)).toBeVisible();
await moreButton.click();
await expect(moreButton).toHaveAttribute("aria-pressed", "false");
await toggle.click();
await expect
.poll(() =>
@@ -112,8 +238,44 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
await page.setViewportSize({ width: 390, height: 844 });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
() =>
page.evaluate(
() =>
window.__waterNetworkMap?.queryRenderedFeatures({ layers: ["fengxian-city-buildings"] })
.length ?? 0
),
{ timeout: 30000 }
)
.toBeGreaterThan(0);
await waitForMapIdle(page);
const mobileStationaryPerformance = await measureFramePerformance(page);
const mobileRotationPerformance = await measureFramePerformance(page, -45);
expect(mobileStationaryPerformance.frames).toBeGreaterThan(5);
expect(mobileRotationPerformance.frames).toBeGreaterThan(5);
const box = await toggle.boundingBox();
expect(box?.width).toBe(40);
expect(box?.height).toBe(40);
await page.screenshot({ path: "../city-model-fengxian/reports/browser-mobile.png" });
await writeFile(
"../city-model-fengxian/reports/browser-performance.json",
`${JSON.stringify(
{
median_frame_ms: desktopRotationPerformance.median_frame_ms,
p95_frame_ms: desktopRotationPerformance.p95_frame_ms,
samples: {
desktop_stationary: desktopStationaryPerformance,
desktop_rotation: desktopRotationPerformance,
mobile_stationary: mobileStationaryPerformance,
mobile_rotation: mobileRotationPerformance
},
environment:
"Playwright Chromium on this workstation; stationary and rotating desktop/mobile 3D views with MSAA"
},
null,
2
)}\n`,
"utf8"
);
});
+2
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
test("Dev Panel opens as a floating desktop panel and closes with Escape", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
await page.waitForFunction(() => Boolean(
@@ -52,6 +53,7 @@ test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
});
test("labels remain active per business group and clear globally", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("workbench-value-label-pipes")
+5 -1
View File
@@ -142,9 +142,13 @@ test.describe("mobile workbench sheet", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await sheet.getByRole("button", { name: "展开工况任务" }).click();
await sheet.getByRole("button", { name: /预约任务/ }).click();
await sheet.getByRole("button", { name: "收起工况任务" }).click();
const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" })
.last();
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await awaitingReplyWorkOrder.click();
@@ -0,0 +1,455 @@
import { expect, test } from "@playwright/test";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskSummary
} from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const taskId = "72000000-0000-4000-8000-000000000001";
const appointmentTaskId = "72000000-0000-4000-8000-000000000002";
const actionId = "73000000-0000-4000-8000-000000000001";
test("审批动作展示中文参数和证据,viewer 不可审批,冲突后可核对未知回执", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_MAP_URL: "http://map.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
})}`
}));
await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: {
items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }],
total: 1, limit: 1000, offset: 0
} }));
await page.route("http://server.test/api/v1/scada-devices**", (route) =>
route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } })
);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
const baseAction: OperationalTaskAction = {
action_id: actionId,
task_id: taskId,
version: 3,
kind: "issue_command",
node_id: null,
reason: "隔离疑似漏损支路前需要调度员确认阀门目标与开度",
payload: {
command_type: "set_valve",
target: { kind: "valve", id: "V-102" },
parameters: { opening: 35 },
impact_summary: "隔离疑似漏损支路,可能短时影响演示小区供水压力",
preconditions: ["上游压力不低于 28m", "现场人员已到位"],
expires_at: "2026-09-23T09:00:00Z"
},
evidence_refs: ["e-pressure"],
required_approval_permission: "operational_action.command.approve",
required_execution_permission: "operational_action.command.execute",
approval_status: "pending",
execution_status: "not_submitted",
execution_request_id: null,
proposed_by: "agent",
proposed_at: "2026-09-23T08:25:00Z",
decided_by: null,
decided_at: null,
decision_comment: null,
executed_by: null,
executed_at: null,
receipt: null,
execution_detail: null
};
let task: OperationalTask = {
project_id: projectId,
task_id: taskId,
task_type: "event_investigation",
workflow: "burst_report",
lifecycle_status: "waiting_human",
active_run: null,
status: "waiting_human",
priority: "urgent",
revision: 2,
execution_generation: 2,
execution_request_id: "74000000-0000-4000-8000-000000000001",
title: "阀门调节审批",
summary: "爆管事件调查需要确认隔离阀门动作",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst_report" },
trigger: { kind: "manual", occurred_at: "2026-09-23T08:20:00Z", detail: "演示路与样板路交叉口" },
scheduled_for: null,
agent_session_id: "approval-session",
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: 1,
created_at: "2026-09-23T08:20:00Z",
updated_at: "2026-09-23T08:25:00Z",
latest_plan: null,
latest_report: {
revision: 1,
execution_generation: 1,
source_session_id: "approval-session",
published_at: "2026-09-23T08:24:00Z",
published_by: "agent",
report: {
title: "爆管事件调查报告",
current_conclusion: "疑似支路漏损,阀门动作尚未执行。",
conclusion_boundary: "等待人工确认和设备回执。",
evidence_refs: ["e-pressure"],
recommendations: ["确认 V-102 目标与开度后再执行"],
pending_items: ["调度员确认阀门动作"]
}
},
evidence: [{
evidence_id: "e-pressure",
kind: "system_data",
summary: "V-102 下游压力在五分钟内下降 8.2m",
source_label: "SCADA 压力监测",
source_ref: "PRESSURE-V102",
observed_at: "2026-09-23T08:18:00Z",
recorded_at: "2026-09-23T08:23:00Z",
recorded_by: "agent",
has_snapshot: false
}],
actions: [baseAction],
permissions: {
can_publish_artifacts: false,
can_propose_actions: false,
can_sync_external_feedback: false,
can_decide_work_orders: false,
can_execute_work_orders: false,
can_decide_commands: false,
can_execute_commands: false
},
work_order_snapshot: null
};
const appointmentTask: OperationalTask = {
...task,
task_id: appointmentTaskId,
task_type: "external_work_order",
workflow: "general",
lifecycle_status: "open",
active_run: null,
status: "queued",
priority: "normal",
execution_request_id: "74000000-0000-4000-8000-000000000002",
title: "城北阀井预约维修",
summary: "外围工单同步的预约维修任务",
source: {
kind: "external_system",
external_id: "sample-work-order-1",
external_revision: 2,
event_kind: "general"
},
trigger: {
kind: "external_sync",
occurred_at: "2026-09-23T08:30:00Z",
detail: "预约 09/23 18:00 由北片区维修组处理"
},
scheduled_for: "2026-09-23T10:00:00Z",
agent_session_id: null,
current_report_revision: null,
latest_report: null,
evidence: [],
actions: [],
work_order_snapshot: {
work_order_id: "50000000-0000-4000-8000-000000000001",
revision: 2,
title: "城北阀井预约维修",
summary: "编辑后的预约维修说明",
status: "open",
priority: "normal",
assignee: "测试班组乙",
scheduled_at: "2026-09-23T10:00:00Z",
work_order_type: "general",
location: "联调测试点东侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: [{
id: "field-service",
title: "阀井现场维修",
summary: "按预约窗口完成现场维修并回填结果。",
status: "pending",
scheduled_at: "2026-09-23T10:00:00Z",
location: "联调测试点东侧",
assignee: "测试班组乙",
duration_minutes: 60,
reply_window_minutes: 30,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "pending", owner: "调度岗", time_label: "待安排", description: "确认预约窗口。" },
{ id: "execution", sequence: 2, title: "执行", status: "pending", owner: "测试班组乙", time_label: "待安排", description: "完成现场维修。" },
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "调度岗", time_label: "待安排", description: "核对回执并闭环。" }
],
reply_requirements: ["回填现场处理结果"]
}],
origin: "synthetic",
updated_at: "2026-09-23T08:30:00Z"
}
};
const summary = (item: OperationalTask): OperationalTaskSummary => {
const {
latest_plan,
latest_report,
evidence,
actions,
permissions,
work_order_snapshot,
...taskSummary
} = item;
void latest_plan; void latest_report; void evidence; void actions; void permissions; void work_order_snapshot;
return taskSummary;
};
let scheduleRequests = 0;
const runtimeSnapshot = {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
};
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: runtimeSnapshot,
tasks: [summary(task), summary(appointmentTask)], notifications: [],
generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname.endsWith("/operational-tasks/schedules")) {
scheduleRequests += 1;
return route.fulfill({ json: { items: [] } });
}
if (request.method() === "GET" && pathname.endsWith("/operational-tasks")) {
return route.fulfill({ json: { items: [summary(task), summary(appointmentTask)], limit: 50, offset: 0, next_offset: null } });
}
if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${taskId}`)) {
return route.fulfill({ json: task });
}
if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${appointmentTaskId}`)) {
return route.fulfill({ json: appointmentTask });
}
if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/decision`)) {
return route.fulfill({ status: 409, json: { detail: "动作版本冲突:expected 3, actual 4" } });
}
if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/reconcile`)) {
const receiptAction = {
...task.actions[0],
version: 5,
execution_status: "succeeded",
receipt: {
adapter: "scada-command-adapter",
receipt_id: "SCADA-RECEIPT-20260923-01",
submitted_at: "2026-09-23T08:28:00Z",
completed_at: "2026-09-23T08:29:00Z",
external_status: "completed",
detail: "V-102 开度已调整至 35%"
},
execution_detail: "设备回执确认完成"
};
task = { ...task, actions: [receiptAction] };
return route.fulfill({ json: receiptAction });
}
return route.fulfill({ status: 404, json: {} });
});
const openTask = async () => {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await conditionPanel.getByText("阀门调节审批", { exact: true }).first().click();
return conditionPanel;
};
await page.goto("/");
let conditionPanel = await openTask();
await expect(conditionPanel.getByRole("navigation", { name: "工况任务分类" })).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("演示");
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
await conditionPanel.getByRole("button", { name: /预约任务/ }).click();
await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/^演示工单:/).first()).toBeVisible();
await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click();
await expect(conditionPanel.getByText(/阀井现场维修.*待处理/)).toBeVisible();
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).toBeVisible();
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0);
await page.waitForTimeout(3_500);
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: "/tmp/tjwater-operational-ui-review/task-categories-desktop.png" });
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
expect(scheduleRequests).toBe(0);
await expect(conditionPanel.getByText("上一轮报告,当前任务待人工", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText(/第\s*[12]\s*代|generation/i)).toHaveCount(0);
const actionCard = conditionPanel.locator(`[data-action-id="${actionId}"]`);
await expect(actionCard.getByText("命令类型", { exact: true })).toBeVisible();
await expect(actionCard.getByText("对象类型", { exact: true })).toBeVisible();
await expect(actionCard.getByText("目标对象", { exact: true })).toBeVisible();
await expect(actionCard.getByText("执行参数", { exact: true })).toBeVisible();
await expect(actionCard.getByText("影响说明", { exact: true })).toBeVisible();
await expect(actionCard.getByText("调节阀门", { exact: true })).toBeVisible();
await expect(actionCard.getByText("阀门", { exact: true })).toBeVisible();
await expect(actionCard.getByText("开度 35%", { exact: true })).toBeVisible();
await expect(actionCard.getByText("确认依据", { exact: true })).toBeVisible();
await expect(actionCard.getByText(/V-102 下游压力.*SCADA 压力监测/)).toBeVisible();
await expect(actionCard.getByText(/当前账号仅可查看/)).toBeVisible();
await expect(actionCard.getByText(/operational_action\.command\.approve/)).toHaveCount(0);
await expect(actionCard.getByRole("button", { name: "批准下发命令" })).toHaveCount(0);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("approval-viewer-desktop", {
body: await actionCard.screenshot({ path: testInfo.outputPath("approval-viewer-desktop.png") }),
contentType: "image/png"
});
task = {
...task,
permissions: { ...task.permissions, can_decide_commands: true }
};
await page.reload();
conditionPanel = await openTask();
await conditionPanel.getByRole("button", { name: "批准下发命令" }).click();
await expect(conditionPanel.getByRole("alert")).toContainText("动作版本冲突:expected 3, actual 4");
const refreshButton = conditionPanel.getByRole("button", { name: "刷新后重新确认" });
task = {
...task,
actions: [{
...task.actions[0],
version: 4,
approval_status: "approved",
execution_status: "unknown",
execution_request_id: "75000000-0000-4000-8000-000000000001",
decided_by: "dispatcher",
decided_at: "2026-09-23T08:27:00Z"
}],
permissions: { ...task.permissions, can_execute_commands: true }
};
await refreshButton.click();
const reconcileButton = conditionPanel.getByRole("button", { name: "核对执行回执" });
await expect(reconcileButton).toBeVisible();
await reconcileButton.click();
const receipt = conditionPanel.getByRole("region", { name: "执行回执" });
await expect(receipt).toContainText("SCADA-RECEIPT-20260923-01");
await expect(receipt).toContainText("已完成");
await expect(receipt).toContainText("V-102 开度已调整至 35%");
await testInfo.attach("approval-receipt-desktop", {
body: await actionCard.screenshot({ path: testInfo.outputPath("approval-receipt-desktop.png") }),
contentType: "image/png"
});
});
test("后端没有调查任务时显示空态,分类标签按压不缩放", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_MAP_URL: "http://map.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
})}`
}));
await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: {
items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }],
total: 1, limit: 1000, offset: 0
} }));
await page.route("http://server.test/api/v1/scada-devices**", (route) =>
route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } })
);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks**", (route) =>
route.fulfill({ json: { items: [], limit: 50, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0,
runtime: {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
},
tasks: [], notifications: [], generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
await page.goto("/");
await page.getByRole("button", { name: "工况任务", exact: true }).press("Enter");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByText("暂无任务", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("新任务进入当前分类后会出现在这里。", { exact: true })).toBeVisible();
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 900 });
if (width <= 375) {
const openMobileTasks = page.getByRole("button", { name: "打开工况任务", exact: true });
if (await openMobileTasks.isVisible()) await openMobileTasks.click();
const expandMobile = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandMobile.isVisible()) await expandMobile.click();
}
const categories = conditionPanel.getByRole("navigation", { name: "工况任务分类" });
for (const label of ["定时任务", "调查任务", "预约任务"]) {
const tab = categories.getByRole("button", { name: new RegExp(label) });
await tab.hover();
const before = await tab.boundingBox();
expect(before).not.toBeNull();
await page.mouse.down();
try {
await tab.evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished));
});
await expect.poll(() => tab.evaluate((element) => getComputedStyle(element).scale)).toBe("none");
const pressed = await tab.boundingBox();
expect(pressed).not.toBeNull();
expect(Math.abs(pressed!.width - before!.width)).toBeLessThan(0.5);
expect(Math.abs(pressed!.height - before!.height)).toBeLessThan(0.5);
} finally {
await page.mouse.up();
}
await expect(tab).toHaveAttribute("aria-current", "page");
}
await categories.screenshot({ path: testInfo.outputPath(`task-tabs-${width}.png`) });
}
await page.emulateMedia({ reducedMotion: "reduce" });
const investigationTab = conditionPanel.getByRole("navigation", { name: "工况任务分类" })
.getByRole("button", { name: /调查任务/ });
await investigationTab.focus();
await page.keyboard.press("Enter");
await expect(investigationTab).toHaveAttribute("aria-current", "page");
await expect(investigationTab).toBeFocused();
await expect.poll(() => investigationTab.evaluate((element) => getComputedStyle(element).transitionProperty)).toBe("none");
});
+348 -318
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.describe("外围样板管理", () => {
test.describe("外围工单测试", () => {
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) =>
@@ -37,9 +37,8 @@ test.describe("外围样板管理", () => {
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
});
test("展示已配置的历史回放与合成样本", async ({ page }, testInfo) => {
const observationQueries: Record<string, string>[] = [];
const writeRequests: string[] = [];
test("外围面板只读取工单能力,不再请求监测回放与测试数据", async ({ page }) => {
const requestedPaths: string[] = [];
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "text/javascript",
@@ -47,9 +46,7 @@ test.describe("外围样板管理", () => {
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo",
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T08:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T16:00:00Z"
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo"
})};`
})
);
@@ -69,72 +66,16 @@ test.describe("外围样板管理", () => {
}
})
);
const feedback = {
feedback_id: "40000000-0000-4000-8000-000000000001",
task_id: "10000000-0000-4000-8000-000000000001",
recorded_by: "operator",
recorded_at: "2026-04-30T09:10:00Z",
occurred_at: "2026-04-30T09:00:00Z",
applicable_time: { start: "2026-04-30T08:00:00Z", end: "2026-04-30T16:00:00Z" },
asset_ids: ["DEMO-P001"],
finding: "instrument_fault",
notes: "压力变送器零点漂移",
attachment_refs: [],
origin: "synthetic"
};
const task = {
task_id: feedback.task_id,
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 3,
title: "DEMO-P001 测点偏差核查",
summary: "校验记录外围核查",
source: {
system: "next",
task_id: "20000000-0000-4000-8000-000000000001",
object_type: "validation",
object_id: "30000000-0000-4000-8000-000000000001",
revision: 2
},
asset_ids: ["DEMO-P001"],
analysis_window: { start: "2026-04-30T08:00:00Z", end: "2026-04-30T16:00:00Z" },
evidence_refs: [],
requested_check: "核查仪表状态",
status: "pending_review",
origin: "synthetic",
created_at: "2026-04-30T08:05:00Z",
updated_at: "2026-04-30T09:10:00Z",
created_by: "operator",
history: [
{
sequence: 1,
from_status: null,
to_status: "pending",
actor: "operator",
at: "2026-04-30T08:05:00Z",
reason: "提交样板核查"
},
{
sequence: 2,
from_status: "processing",
to_status: "pending_review",
actor: "operator",
at: "2026-04-30T09:10:00Z",
reason: "等待审核"
}
],
feedback: [feedback]
};
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const url = new URL(route.request().url());
if (route.request().method() !== "GET") writeRequests.push(route.request().url());
if (url.pathname.endsWith("/monitoring/observations"))
observationQueries.push(Object.fromEntries(url.searchParams));
requestedPaths.push(url.pathname);
if (url.pathname.endsWith("/context"))
return route.fulfill({
json: {
protocol_version: "tjwater-peripheral-sample@1",
origin: "synthetic",
project_id: task.project_id,
project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001",
can_write: true,
scenarios: ["normal"],
@@ -147,68 +88,8 @@ test.describe("外围样板管理", () => {
}
}
});
if (url.pathname.endsWith("/scenario"))
return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (url.pathname.endsWith("/tasks"))
return route.fulfill({ json: { items: [task], next_cursor: null } });
if (url.pathname.endsWith("/work-orders"))
return route.fulfill({ json: { items: createSourceWorkOrders(task.project_id), next_cursor: null } });
if (url.pathname.endsWith("/test-data"))
return route.fulfill({ json: { items: [], next_cursor: null } });
if (url.pathname.endsWith("/monitoring/points"))
return route.fulfill({
json: {
items: Array.from({ length: 118 }, (_, index) => ({
point_id: `SCADA-${index + 1}`,
label: `压力测点 ${index + 1}`,
unit: "m",
status: "active",
origin: "historical_replay"
})),
next_cursor: null,
origin: "historical_replay",
source_id: "tjwater_v2"
}
});
if (url.pathname.endsWith("/monitoring/alerts"))
return route.fulfill({
json: {
availability: "not_configured",
items: [],
origin: "historical_replay",
reason: "来源未配置",
source_id: "tjwater_v2"
}
});
if (url.pathname.endsWith("/monitoring/observations"))
return route.fulfill({
json: {
origin: "historical_replay",
source_id: "tjwater_v2",
source_revision: "2026-04-30",
truncated: false,
items: [
{
record_id: "reading-1",
point_id: "SCADA-1",
observed_at: "2026-04-30T08:15:00Z",
value: 31.42,
quality: "valid",
unit: "m"
},
{
record_id: "reading-2",
point_id: "SCADA-1",
observed_at: "2026-04-30T08:30:00Z",
value: null,
quality: "missing",
unit: "m"
}
]
}
});
if (url.pathname.endsWith("/demo-data"))
return route.fulfill({ json: createDemoDataset(task.project_id) });
return route.fulfill({ json: { items: createSourceWorkOrders(projectId), next_cursor: null } });
return route.fulfill({
status: 404,
json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null }
@@ -216,7 +97,11 @@ test.describe("外围样板管理", () => {
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel).toBeVisible();
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
const workOrderList = page.getByLabel("工单列表");
await expect(workOrderList).toContainText("合成通信核查");
await expect(workOrderList).toContainText("合成压力偏差复核");
@@ -224,114 +109,12 @@ test.describe("外围样板管理", () => {
await expect(workOrderList).toContainText("待处理");
await expect(workOrderList).toContainText("处理中");
await expect(workOrderList).toContainText("已完成");
await expect(page.getByLabel("工单步骤 合成通信核查")).toContainText(/派发.*当前步骤.*执行.*待开始.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前步骤.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/);
await expect(workOrderList).not.toContainText("外围合成数据核查");
await expect(workOrderList).not.toContainText("子任务");
await testInfo.attach("peripheral-work-orders-1280", {
body: await workOrderList.screenshot(),
contentType: "image/png"
});
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const firstOrderSteps = page.getByLabel("工单步骤 合成通信核查");
await firstOrderSteps.scrollIntoViewIfNeeded();
await expect(firstOrderSteps).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-orders-${width}`, {
body: await page.screenshot(),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const scenarioSelect = page.getByRole("combobox", { name: "场景" });
await scenarioSelect.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("listbox")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("listbox")).toBeHidden();
await expect(scenarioSelect).toBeFocused();
const pointSelect = page.getByRole("combobox", { name: "测点", exact: true });
await pointSelect.click();
await page.getByRole("option", { name: "压力测点 2 · m" }).click();
await expect(pointSelect).toContainText("压力测点 2 · m");
const historyMode = page.getByRole("combobox", { name: "时间模式", exact: true });
const historyStart = page.getByRole("button", { name: "开始(UTC)", exact: true });
const historyEnd = page.getByRole("button", { name: "结束(UTC)", exact: true });
await expect(historyStart).toContainText("2026-04-30 08:00");
await historyMode.click();
await page.getByRole("option", { name: "最近 8 小时", exact: true }).click();
await expect(page.getByText(/UTC 历史窗口 · 最近 8 小时/)).toBeVisible();
await expect(historyStart).not.toContainText("2026-04-30 08:00");
await historyMode.click();
await page.getByRole("option", { name: "演示历史窗口", exact: true }).click();
await expect(historyStart).toContainText("2026-04-30 08:00");
await expect(historyEnd).toContainText("2026-04-30 16:00");
await historyStart.click();
const startDialog = page.getByRole("dialog", { name: "选择开始(UTC)" });
await startDialog.getByRole("gridcell", { name: "2026年4月29日", exact: true }).click();
await startDialog.getByRole("combobox", { name: "分钟" }).click();
await page.getByRole("option", { name: "15", exact: true }).click();
await startDialog.getByRole("button", { name: "确认", exact: true }).click();
await expect(historyStart).toContainText("2026-04-29 08:15");
await historyEnd.click();
const endDialog = page.getByRole("dialog", { name: "选择结束(UTC)" });
await endDialog.getByRole("combobox", { name: "分钟" }).click();
await page.getByRole("option", { name: "45", exact: true }).click();
await endDialog.getByRole("button", { name: "确认", exact: true }).click();
await expect(historyEnd).toContainText("2026-04-30 16:45");
expect(observationQueries).toEqual([]);
await page.getByRole("button", { name: "读取历史读数" }).click();
await expect(page.getByText("31.42 m")).toBeVisible();
await expect(page.getByText("缺失")).toBeVisible();
expect(observationQueries).toEqual([
{ point_id: "SCADA-2", start: "2026-04-29T08:15:00Z", end: "2026-04-30T16:45:00Z" }
]);
await page.getByText("合成演示样本", { exact: true }).click();
await page.getByRole("button", { name: "手动读取合成样本" }).click();
await expect(
page.getByText(/5 个虚拟测点,240 条读数,9 条状态,2 条告警,4 条运行事件/)
).toBeVisible();
await expect(page.getByText("0 m", { exact: true }).first()).toBeVisible();
await expect(page.getByText("缺失", { exact: true }).first()).toBeVisible();
for (const width of [320, 375, 1280]) {
await page.setViewportSize({ width, height: 720 });
await pointSelect.scrollIntoViewIfNeeded();
await pointSelect.click();
await expect(page.getByRole("listbox")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
width
);
await testInfo.attach(`peripheral-select-${width}px`, {
body: await page.screenshot(),
contentType: "image/png"
});
await page.keyboard.press("Escape");
await historyStart.click();
await expect(startDialog).toBeVisible();
await expect(
startDialog.locator(
'input[type="datetime-local"], input[type="date"], input[type="time"], select'
)
).toHaveCount(0);
await startDialog.getByRole("gridcell", { name: "2026年4月28日", exact: true }).click();
const calendarBox = await startDialog.boundingBox();
expect(calendarBox!.x).toBeGreaterThanOrEqual(0);
expect(calendarBox!.x + calendarBox!.width).toBeLessThanOrEqual(width);
expect(
await startDialog.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true);
await page.screenshot({ path: `test-results/peripheral-datetime/${width}-calendar.png` });
await page.keyboard.press("Escape");
await expect(startDialog).toBeHidden();
await expect(historyStart).toBeFocused();
await expect(historyStart).toContainText("2026-04-29 08:15");
}
expect(observationQueries).toHaveLength(1);
expect(writeRequests).toEqual([]);
await expect(workOrderList.getByText("未提供过程", { exact: true })).toHaveCount(3);
expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context",
"/api/sample/v1/work-orders"
]));
expect(requestedPaths.some((path) => path.includes("/monitoring/") || path.endsWith("/test-data") || path.endsWith("/demo-data"))).toBe(false);
});
test("未配置时从顶栏进入且不暗连外围服务", async ({ page }) => {
@@ -341,17 +124,72 @@ test.describe("外围样板管理", () => {
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByRole("complementary", { name: "外围样板管理" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
await expect(
page
.getByRole("complementary", { name: "外围样板管理" })
.getByText("外围系统模拟接入,不是正式工单/现场结果", { exact: true })
.getByRole("complementary", { name: "外围工单测试" })
.getByText("仅用于验证外围工单接口与同步过程,不代表真实现场处置", { exact: true })
.last()
).toBeVisible();
await expect(page.getByText(/当前不会连接任何外围服务/)).toBeVisible();
await expect(page.getByText(/当前不会连接外围工单服务/)).toBeVisible();
expect(peripheralRequests).toEqual([]);
});
test("管理面板打开时阻止地图接收鼠标行为", async ({ page }) => {
await page.goto("/");
await page.locator(".maplibregl-canvas-container").evaluate((element) => {
const probeWindow = window as Window & { __mapPointerEvents?: string[] };
probeWindow.__mapPointerEvents = [];
for (const type of ["pointerdown", "wheel"] as const) {
element.addEventListener(type, () => probeWindow.__mapPointerEvents?.push(type), true);
}
});
const expectMapInteractionBlocked = async (panelName: string) => {
const panel = page.getByRole("complementary", { name: panelName });
const [panelBox, canvasBox] = await Promise.all([
panel.boundingBox(),
page.locator(".maplibregl-canvas").boundingBox()
]);
expect(panelBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
const x = panelBox!.x - 24;
const y = panelBox!.y + 120;
expect(x).toBeGreaterThan(canvasBox!.x);
expect(y).toBeLessThan(canvasBox!.y + canvasBox!.height);
await expect
.poll(() =>
page.evaluate(
({ x: pointX, y: pointY }) =>
document.elementFromPoint(pointX, pointY)?.hasAttribute("data-map-interaction-shield"),
{ x, y }
)
)
.toBe(true);
await page.mouse.click(x, y);
await page.mouse.wheel(0, 120);
await expect
.poll(() =>
page.evaluate(
() => (window as Window & { __mapPointerEvents?: string[] }).__mapPointerEvents
)
)
.toEqual([]);
};
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
await expectMapInteractionBlocked("外围工单测试");
await page.getByRole("button", { name: "外围样板管理" }).click();
await page.getByRole("button", { name: "项目分析成果" }).click();
await expect(page.getByRole("complementary", { name: "项目分析成果" })).toBeVisible();
await expectMapInteractionBlocked("项目分析成果");
});
test("增量存储未供给时保留原有外围能力且不请求缺表资源", async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const managedRequests: string[] = [];
@@ -390,17 +228,18 @@ test.describe("外围样板管理", () => {
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel.getByText("测试工单存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("连接与演练场景", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
expect(managedRequests).toEqual([]);
});
test("创建、修改并软删除测试工单和测试数据", async ({ page }, testInfo) => {
test("创建编辑真实过程、刷新外部更新并软删除预约工单", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
let workOrders: Record<string, unknown>[] = [];
let testData: Record<string, unknown>[] = [];
const writes: Record<string, unknown>[] = [];
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test", TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test" })}`
@@ -411,83 +250,168 @@ test.describe("外围样板管理", () => {
const path = url.pathname;
const now = "2026-09-18T00:00:00Z";
if (path.endsWith("/context")) return route.fulfill({ json: { protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: { pending: ["processing"], processing: ["pending_review"], pending_review: ["processing", "completed"], completed: [] } } });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/tasks")) return route.fulfill({ json: { items: [], next_cursor: null } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: { items: [], next_cursor: null, origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: { items: [], availability: "not_configured", reason: "未配置", origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/demo-data")) return route.fulfill({ json: createDemoDataset(projectId) });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
writes.push(body);
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester" };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/work-orders/") && request.method() === "PATCH") {
const body = request.postDataJSON(); workOrders = [{ ...workOrders[0], ...body, revision: 2 }]; delete workOrders[0].request_id; delete workOrders[0].expected_revision;
const body = request.postDataJSON();
writes.push(body);
const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z" };
if (body.clear_assignee) next.assignee = null;
if (body.clear_location) next.location = null;
if (body.clear_scenario_code) next.scenario_code = null;
if (body.clear_scheduled_at) next.scheduled_at = null;
if (body.clear_subtasks) next.subtasks = null;
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "clear_subtasks"]) delete next[key];
workOrders = [next];
return route.fulfill({ json: workOrders[0] });
}
if (path.includes("/work-orders/") && request.method() === "DELETE") { workOrders = []; return route.fulfill({ json: { resource_type: "work_order", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
if (path.endsWith("/test-data") && request.method() === "GET") return route.fulfill({ json: { items: testData, next_cursor: null } });
if (path.endsWith("/test-data") && request.method() === "POST") {
const body = request.postDataJSON(); const item = { ...body, record_id: "60000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester" };
delete item.request_id; testData = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/test-data/") && request.method() === "PATCH") { const body = request.postDataJSON(); testData = [{ ...testData[0], ...body, revision: 2 }]; delete testData[0].request_id; delete testData[0].expected_revision; return route.fulfill({ json: testData[0] }); }
if (path.includes("/test-data/") && request.method() === "DELETE") { testData = []; return route.fulfill({ json: { resource_type: "test_data_record", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await expect(panel.getByText("外围工单与测试数据", { exact: true })).toBeVisible();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByLabel("工单列表")).toContainText("暂无测试工单");
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
await panel.getByRole("button", { name: "新增工单" }).click();
const workOrderEditor = panel.getByRole("textbox", { name: "标题" }).locator("xpath=../..");
await expect(workOrderEditor).toHaveCSS("background-color", "rgb(255, 255, 255)");
await testInfo.attach("work-order-editor-1280", {
body: await workOrderEditor.screenshot(),
await panel.getByRole("button", { name: "新增预约工单" }).click();
let editor = panel.getByRole("region", { name: "新增预约工单" });
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修");
await editor.getByRole("textbox", { name: "说明", exact: true }).fill("验证外围过程结构同步");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组甲和西侧作业窗口。");
await editor.getByRole("button", { name: "创建预约工单" }).click();
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
expect(writes[0]).toMatchObject({
work_order_type: "general",
assignee: "测试班组甲",
location: "联调测试点西侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组甲",
location: "联调测试点西侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组甲和西侧作业窗口。" })])
})])
});
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组乙和东侧作业窗口,等待真实反馈。");
await editor.getByRole("combobox", { name: "工单状态" }).click();
await page.getByRole("option", { name: "待复令", exact: true }).click();
await editor.getByRole("button", { name: "保存工单与过程" }).click();
await expect(panel.getByText("合成预约维修(已对齐)", { exact: true })).toBeVisible();
expect(writes[1]).toMatchObject({
status: "awaiting_reply",
assignee: "测试班组乙",
location: "联调测试点东侧",
clear_assignee: false,
clear_location: false,
clear_subtasks: false,
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组乙",
location: "联调测试点东侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组乙和东侧作业窗口,等待真实反馈。" })])
})])
});
expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" });
workOrders = [{ ...workOrders[0], title: "外部系统更新后的预约维修", revision: 3 }];
await panel.getByRole("button", { name: "刷新外围工单" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
await expect(refreshedEditor.getByRole("textbox", { name: "处置位置" })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "阶段说明", exact: true }).first()).toHaveValue("确认班组乙和东侧作业窗口,等待真实反馈。");
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 });
await refreshedEditor.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-order-process-${width}`, {
body: await page.screenshot({ path: `/tmp/tjwater-operational-ui-review/peripheral-work-order-edit-${width}.png` }),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 780 });
await refreshedEditor.getByRole("button", { name: "关闭编辑表单" }).click();
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeHidden();
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
route.fulfill({ status: 503, json: { detail: "operational task test stub" } })
);
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
await panel.getByRole("combobox", { name: "事件类型" }).click();
await page.getByRole("option", { name: "爆管上报", exact: true }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByText("爆管上报", { exact: true }).last()).toBeVisible();
expect(workOrders[0]).toMatchObject({
work_order_type: "burst_report",
scenario_code: "BURST-REPORT-001",
title: "爆管上报:演示路与样板路交叉口",
status: "open",
priority: "urgent"
});
await page.setViewportSize({ width: 375, height: 720 });
await workOrderEditor.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("work-order-editor-375", {
body: await page.screenshot(),
contentType: "image/png"
});
await page.setViewportSize({ width: 1280, height: 720 });
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单");
await panel.getByRole("textbox", { name: "说明" }).fill("外围 CRUD 联调");
await panel.getByRole("button", { name: "创建工单" }).click();
await expect(panel.getByText("演示 测试工单", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑工单 演示 测试工单" }).click();
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单(已改)");
await panel.getByRole("button", { name: "保存修改" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "删除工单 演示 测试工单(已改)" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeHidden();
await panel.getByRole("group", { name: "外围样板资源类型" }).getByRole("button", { name: /测试数据/ }).click();
await panel.getByRole("button", { name: "新增测试数据" }).click();
await panel.getByRole("textbox", { name: "名称" }).fill("演示 压力样本");
await panel.getByRole("textbox", { name: "数值" }).fill("31.2");
await panel.getByRole("button", { name: "创建数据" }).click();
await expect(panel.getByText("演示 压力样本", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑数据 演示 压力样本" }).click();
await panel.getByRole("textbox", { name: "数值" }).fill("30.8");
await panel.getByRole("button", { name: "保存修改" }).click();
await expect(panel.getByText(/30.8 m/)).toBeVisible();
await panel.getByRole("button", { name: "删除数据 演示 压力样本" }).click();
await expect(panel.getByText("演示 压力样本", { exact: true })).toBeHidden();
});
test("创建水质投诉工单后进入结构化工况任务并由用户打开 Agent", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const workOrderId = "50000000-0000-4000-8000-000000000099";
const agentRequests: Array<Record<string, unknown>> = [];
let workOrders: Record<string, unknown>[] = [];
const plan = {
schema: "execution-plan@1", title: "水质投诉调查计划", reason: "先完成四路并行核查,汇合后再分两路核实",
nodes: [
{ id: "accept", title: "接收投诉工单", status: "completed", owner: "agent", detail: "已关联合成工单", evidence_refs: ["e-1"], action_refs: [] },
{ id: "upstream", title: "校验上游水质监测", status: "completed", owner: "agent", detail: "上游三个点位指标正常", evidence_refs: ["e-1"], action_refs: [] },
{ id: "upstream-review", title: "复核上游趋势", status: "completed", owner: "agent", detail: "过去两小时趋势稳定", evidence_refs: ["e-1"], action_refs: [] },
{ id: "nearby", title: "核对邻近投诉", status: "running", owner: "agent", detail: "检查投诉空间分布", evidence_refs: [], action_refs: [] },
{ id: "pressure", title: "比对压力波动", status: "completed", owner: "agent", detail: "泵房出水压力稳定", evidence_refs: [], action_refs: [] },
{ id: "hydraulic", title: "校核水力路径", status: "running", owner: "agent", detail: "追踪供水路径", evidence_refs: [], action_refs: [] },
{ id: "valve", title: "排查阀门操作", status: "pending", owner: "agent", detail: "等待水力路径结果", evidence_refs: [], action_refs: [] },
{ id: "maintenance", title: "核对运维记录", status: "waiting", owner: "human", detail: "等待近期冲洗记录", evidence_refs: [], action_refs: [] },
{ id: "stage-judgment", title: "阶段性判断", status: "completed", owner: "agent", detail: "疑似二次供水环节问题,待现场核验。", evidence_refs: ["e-1"], action_refs: [] },
{ id: "secondary", title: "核查二次供水", status: "running", owner: "agent", detail: "核对二次供水设施", evidence_refs: [], action_refs: [] },
{ id: "tank-record", title: "核对水箱记录", status: "waiting", owner: "human", detail: "等待水箱清洗记录", evidence_refs: [], action_refs: [] },
{ id: "sampling", title: "现场采样", status: "waiting", owner: "human", detail: "等待现场采样结果", evidence_refs: [], action_refs: [] },
{ id: "conclusion", title: "形成调查结论", status: "pending", owner: "agent", detail: "两路核验完成后汇总", evidence_refs: [], action_refs: [] }
],
edges: [
{ from: "accept", to: "upstream", condition: "上游监测" }, { from: "upstream", to: "upstream-review", condition: null }, { from: "upstream-review", to: "stage-judgment", condition: null },
{ from: "accept", to: "nearby", condition: "邻近投诉" }, { from: "nearby", to: "stage-judgment", condition: null },
{ from: "accept", to: "pressure", condition: "压力路径" }, { from: "pressure", to: "hydraulic", condition: null }, { from: "hydraulic", to: "valve", condition: null }, { from: "valve", to: "stage-judgment", condition: null },
{ from: "accept", to: "maintenance", condition: "运维记录" }, { from: "maintenance", to: "stage-judgment", condition: null },
{ from: "stage-judgment", to: "secondary", condition: "设施核验" }, { from: "secondary", to: "tank-record", condition: null }, { from: "tank-record", to: "conclusion", condition: null },
{ from: "stage-judgment", to: "sampling", condition: "现场采样" }, { from: "sampling", to: "conclusion", condition: null }
]
};
let operationalTask: Record<string, unknown> | null = null;
const summarizeTask = () => operationalTask
? Object.fromEntries(Object.entries(operationalTask).filter(([key]) => ![
"latest_plan", "latest_report", "evidence", "actions", "permissions", "work_order_snapshot"
].includes(key)))
: null;
page.on("request", (request) => {
if (
request.method() === "POST" &&
@@ -540,25 +464,107 @@ test.describe("外围样板管理", () => {
if (path.endsWith("/test-data")) return route.fulfill({ json: { items: [], next_cursor: null } });
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks/schedules", (route) =>
route.fulfill({ json: { items: [] } })
);
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1",
event_cursor: 0,
runtime: {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
},
tasks: summarizeTask() ? [summarizeTask()] : [],
notifications: [],
generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks/schedules")) {
return route.fulfill({ json: { items: [] } });
}
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = {
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal",
revision: 2, execution_generation: 1, execution_request_id: "71000000-0000-4000-8000-000000000001",
title: "水质投诉调查", summary: input.summary, source: input.source, trigger: input.trigger,
scheduled_for: null, agent_session_id: "browser-session-1", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1,
created_at: "2026-09-23T08:20:00Z", updated_at: "2026-09-23T08:25:00Z",
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:08:00Z", published_by: "agent", plan },
latest_report: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:09:00Z", published_by: "agent", report: { title: "水质投诉调查报告", current_conclusion: "疑似二次供水环节问题,待现场核验。", conclusion_boundary: "现场采样和水箱清洗记录尚未返回。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] } },
evidence: [{ evidence_id: "e-1", kind: "synthetic", summary: "上游余氯处于正常范围", source_label: "外围合成场景", source_ref: "WQ-DEMO-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }
};
return route.fulfill({ status: 201, json: operationalTask });
}
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks")) {
const summary = summarizeTask();
return route.fulfill({ json: { items: summary ? [summary] : [], limit: 50, offset: 0, next_offset: null } });
}
if (request.method() === "GET" && operationalTask) return route.fulfill({ json: operationalTask });
return route.fulfill({ status: 404, json: {} });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await panel.getByRole("button", { name: "水质投诉演示" }).click();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示小区");
await panel.getByRole("textbox", { name: "现场描述" }).fill("多户居民反映晨间用水有异味");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeHidden();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible();
await expect(conditionPanel.getByText("演示执行流程 · 水质投诉调查流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("接收投诉工单", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("校验上游水质监测", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("研判供水链路", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("核查二次供水", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("汇总结论与处置", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible();
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(conditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByText("等待水箱清洗记录", { exact: true }).first()).toBeVisible();
const report = conditionPanel.getByRole("region", { name: "任务报告" });
await expect(report.getByText(/疑似二次供水环节问题,待现场核验/)).toBeVisible();
await expect(report.getByText("完整调查报告", { exact: true })).toHaveCount(0);
await expect(report.getByText(/结论边界/)).toBeVisible();
await expect(conditionPanel.getByText("TJWater operational task", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByText("browser-session-1", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(conditionPanel.getByText("前置工作与条件", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(page.getByText("已建立水质投诉调查", { exact: true })).toBeHidden({ timeout: 10_000 });
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("water-quality-condition-task-1280", {
body: await conditionPanel.screenshot(),
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
contentType: "image/png"
});
await conditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
contentType: "image/png"
});
for (const width of [375, 320]) {
@@ -572,16 +578,26 @@ test.describe("外围样板管理", () => {
if (await expandResponsiveTask.isVisible()) {
await expandResponsiveTask.click();
}
await responsiveConditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible();
const responsiveWorkflowStep = responsiveConditionPanel
.getByText("校验上游水质监测", { exact: true })
.last();
const responsiveWorkflowStep = responsiveConditionPanel.locator('[data-execution-node="upstream"]');
await responsiveWorkflowStep.scrollIntoViewIfNeeded();
await expect(responsiveWorkflowStep).toBeVisible();
await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(responsiveConditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(responsiveConditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
await page.waitForTimeout(350);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`water-quality-condition-task-${width}`, {
body: await page.screenshot(),
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
contentType: "image/png"
});
await responsiveConditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
contentType: "image/png"
});
}
@@ -590,9 +606,11 @@ test.describe("外围样板管理", () => {
if (!await restoredConditionPanel.isVisible()) {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
}
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "与 Agent 协同" });
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "Agent 协作" });
if (!await agentCollaborationButton.isVisible()) {
await restoredConditionPanel.getByRole("button", { name: "展开工况任务" }).click();
const expandRestoredTask = restoredConditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandRestoredTask.isVisible()) await expandRestoredTask.click();
await restoredConditionPanel.getByRole("button", { name: /调查任务/ }).click();
}
await agentCollaborationButton.click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
@@ -601,13 +619,21 @@ test.describe("外围样板管理", () => {
scenario_code: "WQ-SECONDARY-SUPPLY-001",
status: "open"
});
const triggerRequests = agentRequests.filter((body) => "trigger_context" in body);
expect(triggerRequests).toHaveLength(2);
expect(new Set(triggerRequests.map((body) =>
(body.trigger_context as { trigger_id: string }).trigger_id
))).toEqual(new Set([
`peripheral.work_order.created:${projectId}:${workOrderId}:1`
]));
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
await page.route("**/api/v1/agent/chat/sessions", (route) => route.fulfill({ json: { sessions: [{
id: "browser-session-1", title: "水质投诉调查", created_at: "2026-09-23T01:00:00Z", updated_at: "2026-09-23T01:00:00Z",
trigger: { subject_id: workOrderId, workflow: "water_quality_complaint", occurred_at: "2026-09-23T01:00:00Z" }
}] } }));
await page.reload();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const recovered = page.getByRole("region", { name: "工况任务", exact: true });
await recovered.getByRole("button", { name: "展开工况任务" }).click();
await recovered.getByRole("button", { name: /调查任务/ }).click();
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
await taskRow.click();
await expect(recovered.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(recovered.getByLabel("并行分枝 2 项")).toBeVisible();
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
});
test("在 375px 视口内完整呈现面板", async ({ page }) => {
@@ -616,7 +642,7 @@ test.describe("外围样板管理", () => {
const launcher = page.getByRole("button", { name: "外围样板管理" });
await launcher.focus();
await page.keyboard.press("Enter");
const panel = page.getByRole("complementary", { name: "外围样板管理" });
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box).not.toBeNull();
@@ -701,6 +727,10 @@ function createSourceWorkOrders(projectId: string) {
priority,
assignee: null,
scheduled_at: "2026-09-17T08:00:00Z",
work_order_type: "general",
location: null,
scenario_code: null,
subtasks: null,
origin: "synthetic",
created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z",
@@ -1,12 +1,13 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
test("uses backend project identity, role and map extent with the existing fallback available", async ({
page
}) => {
await mockWorkbenchContext(page, undefined, {
metadata: {
project_id: "project-1",
project_id: TEST_PROJECT_ID,
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
@@ -22,7 +23,7 @@ test("uses backend project identity, role and map extent with the existing fallb
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "project-1",
project_id: TEST_PROJECT_ID,
project_role: "member",
permissions: ["webgis.view"]
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
import { mockScadaApi, TEST_PROJECT_ID } from "./support/mock-scada-api";
test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => {
await mockRuntimeAndMap(page, { valves: 0, reservoirs: 0, pumps: 0, tanks: 0 });
@@ -35,7 +35,7 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
await page.goto("/", { waitUntil: "domcontentloaded" });
const deviceRequest = await deviceRequestPromise;
expect(deviceRequest.headers()["x-project-id"]).toBe("project-1");
expect(deviceRequest.headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
await expect.poll(async () => page.evaluate(() => {
const map = globalThis.__waterNetworkMap;
if (!map) return [];
@@ -131,7 +131,7 @@ test("loads required map layers while optional layer probes are still pending",
await expect.poll(() => page.evaluate(() => ({
pipes: Boolean(globalThis.__waterNetworkMap?.getLayer("pipes-casing")),
pumps: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
}))).toEqual({ pipes: true, pumps: false });
})), { timeout: 15_000 }).toEqual({ pipes: true, pumps: false });
} finally {
releaseProbe();
}
@@ -180,10 +180,6 @@ test("keeps execution spinners without breathing badges after selecting timeline
.filter({ hasText: "SCADA 数据诊断" })
.filter({ hasText: "执行中" })
.first();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:北辰分区阀门开度调整" });
await expectRunningStatusAnimations(runningCondition);
await runningCondition.click();
await expectRunningStatusAnimations(runningCondition);
@@ -194,6 +190,11 @@ test("keeps execution spinners without breathing badges after selecting timeline
.locator(".status-badge .status-dot")
).toHaveCount(0);
await panel.getByRole("button", { name: /预约任务/ }).click();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:北辰分区阀门开度调整" })
.last();
await expectRunningStatusAnimations(executingWorkOrder);
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
await executingWorkOrder.click();
@@ -214,10 +215,12 @@ test("shows completed field work as a static waiting-for-reply state", async ({
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
await panel.getByRole("button", { name: /预约任务/ }).click();
const awaitingReplyWorkOrder = panel
.getByTestId("scheduled-condition-timeline")
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" })
.last();
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
+45 -4
View File
@@ -11,6 +11,7 @@ type MockAgentApiOptions = {
};
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
const model = options.model ?? {
id: "test/model",
label: "测试模型",
@@ -19,10 +20,41 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
};
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
await page.route("**/api/v1/agent/chat/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id",
"Access-Control-Allow-Methods": "DELETE, GET, PATCH, POST, OPTIONS",
"Access-Control-Allow-Origin": "*"
}
});
return;
}
const pathname = new URL(route.request().url()).pathname;
const collaborationMatch = pathname.match(/\/task\/([^/]+)\/collaboration\/session$/);
if (collaborationMatch && route.request().method() === "POST") {
await route.fulfill({
status: 201,
headers: corsHeaders,
json: {
id: `collaboration-${collaborationMatch[1]}`,
session_id: `collaboration-${collaborationMatch[1]}`,
title: "任务协作问答",
purpose: "task_collaboration",
task_id: collaborationMatch[1],
status: "active",
created_at: "2026-09-23T01:11:00Z",
updated_at: "2026-09-23T01:11:00Z"
}
});
return;
}
if (pathname.endsWith("/session") && route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
const body = (route.request().postDataJSON() ?? {}) as {
trigger_context?: { trigger_id?: string };
};
const triggerId = body.trigger_context?.trigger_id;
@@ -31,6 +63,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (triggerId && !existing) triggeredSessions.set(triggerId, { sessionId, triggerId });
await route.fulfill({
status: existing ? 200 : 201,
headers: corsHeaders,
json: {
session_id: sessionId,
title: "水质投诉事件调查",
@@ -47,11 +80,15 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
const sessionMatch = pathname.match(/\/session\/([^/]+)$/);
if (sessionMatch && route.request().method() === "GET") {
const collaboration = sessionMatch[1].startsWith("collaboration-");
await route.fulfill({
headers: corsHeaders,
json: {
id: sessionMatch[1],
session_id: sessionMatch[1],
title: "水质投诉事件调查",
title: collaboration ? "任务协作问答" : "水质投诉事件调查",
purpose: collaboration ? "task_collaboration" : "ordinary_chat",
task_id: collaboration ? sessionMatch[1].slice("collaboration-".length) : undefined,
status: "active",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
@@ -66,6 +103,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
await route.fulfill({
status: 200,
headers: {
...corsHeaders,
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
@@ -84,12 +122,13 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
}
if (pathname.endsWith("/sessions")) {
await route.fulfill({ json: { sessions: options.sessions ?? [] } });
await route.fulfill({ headers: corsHeaders, json: { sessions: options.sessions ?? [] } });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
headers: corsHeaders,
json: {
default_model: model.id,
models: [model]
@@ -100,6 +139,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (pathname.endsWith("/ui-registry")) {
await route.fulfill({
headers: corsHeaders,
json: {
schema_version: "agent-ui-registry@1",
chart_grammars: [],
@@ -112,6 +152,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (pathname.endsWith("/frontend-action-registry")) {
await route.fulfill({
headers: corsHeaders,
json: {
schema_version: "frontend-action-registry@1",
actions: []
@@ -120,6 +161,6 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
return;
}
await route.fulfill({ json: {} });
await route.fulfill({ headers: corsHeaders, json: {} });
});
}
+54 -2
View File
@@ -16,6 +16,8 @@ const emptyRuntimeSnapshot = {
work_orders: []
};
export const TEST_PROJECT_ID = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
export async function mockScadaApi(
page: Page,
devices: unknown[] = [],
@@ -25,13 +27,27 @@ export async function mockScadaApi(
analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = []
) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
await page.route("https://tjwater-api.invalid/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
...corsHeaders,
"Access-Control-Allow-Headers": "Authorization, Content-Type, Last-Event-ID, X-Project-Id",
"Access-Control-Allow-Methods": "DELETE, GET, PATCH, POST, OPTIONS"
}
});
return;
}
const url = new URL(route.request().url());
if (url.pathname === "/api/v1/projects") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
items: [{ project_id: TEST_PROJECT_ID, gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
@@ -42,6 +58,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/scada-devices") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({ items: devices, total: devices.length, limit: 1000, offset: 0 })
});
return;
@@ -49,6 +66,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/projects/current/metadata" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(projectPresentation.metadata)
});
return;
@@ -56,6 +74,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/access-context" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(projectPresentation.accessContext)
});
return;
@@ -63,6 +82,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/timeseries/scada-readings") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(readings)
});
return;
@@ -70,16 +90,41 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/workbench/runtime") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(runtimeSnapshot)
});
return;
}
if (url.pathname === "/api/v1/workbench/sync-snapshot") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({
schema: "workbench-sync-snapshot@1",
event_cursor: 0,
runtime: runtimeSnapshot,
tasks: [],
notifications: [],
generated_at: "2026-09-17T02:30:00Z"
})
});
return;
}
if (url.pathname === "/api/v1/workbench/events") {
await route.fulfill({
contentType: "text/event-stream",
headers: { ...corsHeaders, "Cache-Control": "no-cache" },
body: ": test keepalive\n\n"
});
return;
}
if (url.pathname === "/api/v1/workbench/analysis-runs") {
const limit = Number(url.searchParams.get("limit") ?? 10);
const offset = Number(url.searchParams.get("offset") ?? 0);
const items = analysisHistory.slice(offset, offset + limit);
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({
items,
limit,
@@ -96,17 +141,24 @@ export async function mockScadaApi(
if (analysisRun) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(analysisRun)
});
} else {
await route.fulfill({
status: 404,
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({ detail: "Analysis run not found" })
});
}
return;
}
await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
await route.fulfill({
status: 404,
contentType: "application/json",
headers: corsHeaders,
body: "{}"
});
});
}
@@ -79,9 +79,9 @@ test("history mode opens on the latest day with backend work orders", async ({ p
await expect(page.getByRole("region", { name: "运行时间轴" })).toContainText("2026.08.17");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 后端工单");
await expect(panel).toContainText("SCADA 数据诊断");
await panel.getByRole("button", { name: "展开工况任务" }).click();
await panel.getByRole("button", { name: /预约任务/ }).click();
await expect(panel).toContainText("历史工单:阀门复核");
await expect(panel).not.toContainText("历史 SCADA 诊断计划");
});
@@ -132,7 +132,6 @@ test("keeps mock conditions while retaining explicit backend alerts", async ({ p
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 未接入工单");
await expect(panel).toContainText("SCADA 数据诊断");
await expect(panel).not.toContainText("演示 合成低压调查");
@@ -298,7 +297,6 @@ test("opens the latest persisted analysis as the active visualization", async ({
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toContainText("工况演示数据 · 未接入工单");
await expect(conditionPanel).not.toContainText("北区爆管定位");
await expect(conditionPanel).not.toContainText("南区历史定位");
await expect(conditionPanel).toContainText("SCADA 数据诊断");
@@ -475,10 +473,10 @@ test("keeps mock conditions while using backend work orders and alerts", async (
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 后端工单");
await expect(panel).toContainText("SCADA 数据诊断");
await expect(panel).not.toContainText("SCADA 后端定时诊断");
await panel.getByRole("button", { name: "展开工况任务" }).click();
await panel.getByRole("button", { name: /预约任务/ }).click();
await expect(panel).toContainText("后端工单:北区阀门复核");
await expect(panel).not.toContainText("工单演示回退");
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 98 KiB