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
@@ -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));
setBusy(null);
}, [capabilities.test_data, capabilities.work_orders, client]);
try {
setOrders((await client.listWorkOrders()).items);
} catch (nextError) {
setError(message(nextError));
} finally {
setBusy(null);
}
}, [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="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
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>}
>
{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.test_data === "available"
? <TestData records={records} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
: null
)}
</WorkbenchSection>;
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>
)}
>
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{capabilities.work_orders !== "available" ? (
<WorkbenchNotice tone="warning" title="测试工单存储待供给">
缺少 peripheral_sample.work_orders 或 work_order_revisions。
</WorkbenchNotice>
) : (
<WorkOrders
orders={orders}
canWrite={canWrite}
busy={busy}
mutate={mutate}
client={client}
onAgentTrigger={onAgentTrigger}
/>
)}
</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>
<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="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>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
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="flex flex-wrap gap-2">
<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>
</div>
</div>
{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))}>
<RefreshCw size={13} />重试触发
</Button>
</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 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(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();
})}
/>
</li>
))}
</ol>
) : (
<WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="创建预约工单或事件工单后,可在这里核对数据库返回结构。" />
)}
</section>
</div>
{agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
<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} />}重试触发
</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 });
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}
canWrite={canWrite}
busy={busy}
onEdit={edit}
onDelete={(item) => 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>;
);
}
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">
<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}
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>
<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>
<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>
<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="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>
<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">
<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.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
</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>
<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"
<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>
{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>
<div className="mt-3">
{process ? (
<WorkOrderSubtasksPanel condition={process} />
) : (
<WorkbenchNotice tone="neutral" title="未提供过程">这条旧工单没有子任务与阶段数据,不根据总状态推测完成情况。</WorkbenchNotice>
)}
</div>
</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>
</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}
<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>
<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>
<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>
<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>
<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">
<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>
{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>
</div>
<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]; }