feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/shared/config/env", () => ({
|
||||
env: { TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test" }
|
||||
}));
|
||||
vi.mock("../api/project-client", () => ({
|
||||
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project-1" } })),
|
||||
createWorkbenchHeaders: (token: string | undefined, projectId: string, json: boolean) => {
|
||||
const headers = new Headers({ Accept: "application/json", "X-Project-Id": projectId });
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (json) headers.set("Content-Type", "application/json");
|
||||
return headers;
|
||||
}
|
||||
}));
|
||||
|
||||
import { PeripheralSampleClient, type PeripheralTask } 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
|
||||
});
|
||||
|
||||
describe("PeripheralSampleClient", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("reads the persisted synthetic demo dataset without issuing a write", async () => {
|
||||
const dataset = { generator_version: "water-demo@1", seed: "fixed", origin: "synthetic", window: { start: "2026-09-17T00:00:00Z", end: "2026-09-17T12:00:00Z" }, points: [], observations: [], device_states: [], alerts: [], operations: [], project_id: "project-1", source_id: "peripheral-demo", source_revision: "demo-r1" };
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(dataset), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect((await new PeripheralSampleClient().getDemoData()).seed).toBe("fixed");
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/api/sample/v1/demo-data");
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.method).toBeUndefined();
|
||||
});
|
||||
|
||||
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 })));
|
||||
const client = new PeripheralSampleClient();
|
||||
|
||||
await expect(client.getDemoData()).rejects.toMatchObject({ code: "INVALID_RESPONSE" });
|
||||
await expect(client.getTask(task(1).task_id)).rejects.toMatchObject({
|
||||
code: "INVALID_RESPONSE",
|
||||
effect: "none"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the newer task 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 })));
|
||||
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);
|
||||
});
|
||||
|
||||
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 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const result = await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source });
|
||||
expect(result.revision).toBe(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retains an unresolved network submission and later queries the same receipt", async () => {
|
||||
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 }));
|
||||
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);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toEqual(fetchMock.mock.calls[2]?.[0]);
|
||||
});
|
||||
|
||||
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 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not claim an unknown write when token acquisition fails before fetch", async () => {
|
||||
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");
|
||||
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 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect((await new PeripheralSampleClient().createTask({ ...task(1), source: task(1).source })).revision).toBe(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("blocks changed command content while an earlier request remains unresolved", async () => {
|
||||
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 }));
|
||||
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" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("manual receipt success clears only the matching pending command", async () => {
|
||||
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 }));
|
||||
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" });
|
||||
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(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,
|
||||
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
|
||||
created_by: "user", updated_by: "user"
|
||||
};
|
||||
const deleted = { resource_type: "work_order" as const, resource_id: order.work_order_id,
|
||||
revision: 3, deleted: true as const };
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(order), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(deleted), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
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
|
||||
})).resolves.toEqual(order);
|
||||
await expect(client.deleteWorkOrder(order.work_order_id, 2)).resolves.toEqual(deleted);
|
||||
|
||||
expect(fetchMock.mock.calls.map(([, init]) => init?.method)).toEqual(["PATCH", "DELETE"]);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ expected_revision: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
|
||||
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"];
|
||||
export type PeripheralAlertPage = Schemas["AlertPage"];
|
||||
export type PeripheralObservations = Schemas["Observations"];
|
||||
export type PeripheralDemoDataset = Schemas["DemoDataset"];
|
||||
export type PeripheralWorkOrder = Schemas["WorkOrder"];
|
||||
export type PeripheralWorkOrderPage = Schemas["WorkOrderPage"];
|
||||
export type PeripheralCreateWorkOrder = Schemas["CreateWorkOrder"];
|
||||
export type PeripheralUpdateWorkOrder = Schemas["UpdateWorkOrder"];
|
||||
export type PeripheralTestDataRecord = Schemas["TestDataRecord"];
|
||||
export type PeripheralTestDataRecordPage = Schemas["TestDataRecordPage"];
|
||||
export type PeripheralCreateTestDataRecord = Schemas["CreateTestDataRecord"];
|
||||
export type PeripheralUpdateTestDataRecord = Schemas["UpdateTestDataRecord"];
|
||||
export type PeripheralDeletedResource = Schemas["DeletedResource"];
|
||||
|
||||
type PeripheralMutationResult = PeripheralTask | PeripheralScenarioState | PeripheralWorkOrder |
|
||||
PeripheralTestDataRecord | PeripheralDeletedResource;
|
||||
|
||||
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;
|
||||
|
||||
constructor(private readonly getAccessToken?: AccessTokenProvider) {}
|
||||
|
||||
get configured() {
|
||||
return Boolean(this.baseUrl);
|
||||
}
|
||||
|
||||
getContext(signal?: AbortSignal) { return this.get<PeripheralContext>("/context", signal); }
|
||||
getScenario(signal?: AbortSignal) { return this.getScenarioProjected("/scenario", signal); }
|
||||
listPoints(signal?: AbortSignal) { return this.get<PeripheralPointPage>("/monitoring/points?limit=500", signal); }
|
||||
listAlerts(signal?: AbortSignal) { return this.get<PeripheralAlertPage>("/monitoring/alerts", signal); }
|
||||
getDemoData(signal?: AbortSignal) { return this.get<PeripheralDemoDataset>("/demo-data", signal); }
|
||||
listWorkOrders(signal?: AbortSignal) { return this.get<PeripheralWorkOrderPage>("/work-orders?limit=100", signal); }
|
||||
listTestData(signal?: AbortSignal) { return this.get<PeripheralTestDataRecordPage>("/test-data?limit=100", signal); }
|
||||
listObservations(pointId: string, start: string, end: string, signal?: AbortSignal) {
|
||||
const query = new URLSearchParams({ point_id: pointId, start, end });
|
||||
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
|
||||
);
|
||||
const result = this.projectResult(receipt.result);
|
||||
for (const [path, pending] of this.pendingCommands) {
|
||||
if (pending.requestId === requestId) this.pendingCommands.delete(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setScenario(scenario: PeripheralScenario, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralScenarioState>("/scenario", {
|
||||
request_id: crypto.randomUUID(), scenario
|
||||
}, 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()
|
||||
}, signal);
|
||||
}
|
||||
|
||||
updateWorkOrder(workOrderId: string, input: Omit<PeripheralUpdateWorkOrder, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralWorkOrder>(
|
||||
`/work-orders/${encodeURIComponent(workOrderId)}`,
|
||||
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
|
||||
);
|
||||
}
|
||||
|
||||
deleteWorkOrder(workOrderId: string, expectedRevision: number, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralDeletedResource>(
|
||||
`/work-orders/${encodeURIComponent(workOrderId)}`,
|
||||
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
createTestData(input: Omit<PeripheralCreateTestDataRecord, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTestDataRecord>("/test-data", {
|
||||
...input, request_id: crypto.randomUUID()
|
||||
}, signal);
|
||||
}
|
||||
|
||||
updateTestData(recordId: string, input: Omit<PeripheralUpdateTestDataRecord, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTestDataRecord>(
|
||||
`/test-data/${encodeURIComponent(recordId)}`,
|
||||
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
|
||||
);
|
||||
}
|
||||
|
||||
deleteTestData(recordId: string, expectedRevision: number, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralDeletedResource>(
|
||||
`/test-data/${encodeURIComponent(recordId)}`,
|
||||
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private async postRecoverable<T extends PeripheralMutationResult>(
|
||||
path: string,
|
||||
payload: { request_id: string } & Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
method: "POST" | "PATCH" | "DELETE" = "POST"
|
||||
): Promise<T> {
|
||||
const commandPath = `${method}:${path}`;
|
||||
const serialized = JSON.stringify(payload);
|
||||
const fingerprint = JSON.stringify(
|
||||
Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "request_id"))
|
||||
);
|
||||
const pending = this.pendingCommands.get(commandPath);
|
||||
if (pending) {
|
||||
if (pending.payload !== fingerprint) {
|
||||
throw new PeripheralSampleError(
|
||||
"PENDING_COMMAND_MISMATCH",
|
||||
`此操作仍有未确认请求 ${pending.requestId},新内容未发送。请先查询原回执。`,
|
||||
"unknown",
|
||||
pending.requestId
|
||||
);
|
||||
}
|
||||
return this.recoverReceipt<T>(commandPath, pending.requestId, signal);
|
||||
}
|
||||
const headers = await this.prepareHeaders(signal, true);
|
||||
try {
|
||||
const result = await this.requestWithHeaders<T>(path, {
|
||||
method, body: serialized, signal, headers
|
||||
}, true);
|
||||
return this.projectResult(result) as T;
|
||||
} catch (error) {
|
||||
if (!(error instanceof PeripheralSampleError) || error.effect !== "unknown") throw error;
|
||||
this.pendingCommands.set(commandPath, { requestId: payload.request_id, payload: fingerprint });
|
||||
return this.recoverReceipt<T>(commandPath, payload.request_id, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverReceipt<T extends PeripheralMutationResult>(
|
||||
commandPath: string,
|
||||
requestId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
try {
|
||||
const receipt = await this.get<Schemas["Receipt"]>(
|
||||
`/requests/${encodeURIComponent(requestId)}`, signal
|
||||
);
|
||||
this.pendingCommands.delete(commandPath);
|
||||
return this.projectResult(receipt.result) as T;
|
||||
} catch (receiptError) {
|
||||
throw new PeripheralSampleError(
|
||||
"UNKNOWN_OUTCOME",
|
||||
`提交结果未知,未自动重试。再次操作只会查询原请求 ${requestId} 的回执。`,
|
||||
"unknown",
|
||||
requestId,
|
||||
{ cause: receiptError }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private get<T>(path: string, signal?: AbortSignal) {
|
||||
return this.request<T>(path, { signal });
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit): Promise<T> {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const headers = await this.prepareHeaders(init.signal ?? undefined, Boolean(init.body));
|
||||
return this.requestWithHeaders<T>(path, { ...init, headers }, false);
|
||||
}
|
||||
|
||||
private async requestWithHeaders<T>(path: string, init: RequestInit, write: boolean): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl}${API_PREFIX}${path}`, init);
|
||||
} catch (error) {
|
||||
if (!write) throw error;
|
||||
throw new PeripheralSampleError("NETWORK_UNKNOWN", "连接中断,提交是否生效未知", "unknown", null, { cause: error });
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
if (write) throw new PeripheralSampleError("INVALID_RESPONSE", "响应无法解析,提交是否生效未知", "unknown", null, { cause: error });
|
||||
throw new PeripheralSampleError("INVALID_RESPONSE", "外围样板响应无法解析", "none", null, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = asSampleError(payload);
|
||||
throw new PeripheralSampleError(
|
||||
detail?.code ?? `HTTP_${response.status}`,
|
||||
detail?.message ?? `外围样板请求失败:HTTP ${response.status}`,
|
||||
detail?.effect ?? (write && response.status >= 500 ? "unknown" : "none"),
|
||||
detail?.request_id ?? null
|
||||
);
|
||||
}
|
||||
const expectedProjectId = new Headers(init.headers).get("X-Project-Id");
|
||||
if (expectedProjectId && !hasOnlyExpectedProjectIds(payload, expectedProjectId)) {
|
||||
throw invalidResponseError(write, "外围样板响应属于其他项目");
|
||||
}
|
||||
if (!write && !isRecognizedReadPayload(path, payload)) {
|
||||
throw invalidResponseError(false, "外围样板响应结构无效");
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const projectId = await this.resolveProjectId(signal);
|
||||
const accessToken = await this.getAccessToken?.();
|
||||
return createWorkbenchHeaders(accessToken, projectId, json);
|
||||
}
|
||||
|
||||
private resolveProjectId(signal?: AbortSignal) {
|
||||
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken, signal)
|
||||
.then(({ project }) => project.project_id)
|
||||
.catch((error) => { this.projectIdPromise = null; throw error; });
|
||||
return this.projectIdPromise;
|
||||
}
|
||||
|
||||
private projectResult(result: PeripheralMutationResult) {
|
||||
if (isTask(result)) return this.projectTask(result);
|
||||
if (isScenarioState(result)) return this.projectScenario(result);
|
||||
if (isManagedResult(result)) return result;
|
||||
throw new PeripheralSampleError("INVALID_RESPONSE", "响应缺少可识别的样板资源,提交是否生效未知", "unknown", null);
|
||||
}
|
||||
|
||||
private projectScenario(next: PeripheralScenarioState) {
|
||||
if (this.scenarioProjection && next.revision < this.scenarioProjection.revision) {
|
||||
return this.scenarioProjection;
|
||||
}
|
||||
this.scenarioProjection = next;
|
||||
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 {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<PeripheralScenarioState>;
|
||||
return typeof item.revision === "number" && typeof item.scenario === "string" &&
|
||||
["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"].includes(item.scenario);
|
||||
}
|
||||
|
||||
function isManagedResult(value: unknown): value is PeripheralWorkOrder | PeripheralTestDataRecord | PeripheralDeletedResource {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (item.deleted === true) return typeof item.resource_id === "string" &&
|
||||
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
|
||||
return (typeof item.work_order_id === "string" || typeof item.record_id === "string") &&
|
||||
typeof item.revision === "number" && item.origin === "synthetic";
|
||||
}
|
||||
|
||||
export class PeripheralSampleError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly effect: "none" | "unknown",
|
||||
readonly requestId: string | null,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "PeripheralSampleError";
|
||||
}
|
||||
}
|
||||
|
||||
function asSampleError(value: unknown): Schemas["SampleError"] | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Partial<Schemas["SampleError"]>;
|
||||
if (typeof item.code !== "string" || typeof item.message !== "string") return null;
|
||||
if (item.effect !== "none" && item.effect !== "unknown") return null;
|
||||
return { code: item.code, message: item.message, effect: item.effect, request_id: item.request_id ?? null };
|
||||
}
|
||||
|
||||
function invalidResponseError(write: boolean, message: string) {
|
||||
return new PeripheralSampleError(
|
||||
"INVALID_RESPONSE",
|
||||
write ? `${message},提交是否生效未知` : message,
|
||||
write ? "unknown" : "none",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function hasOnlyExpectedProjectIds(value: unknown, expectedProjectId: string): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.every((item) => hasOnlyExpectedProjectIds(item, expectedProjectId));
|
||||
}
|
||||
if (!value || typeof value !== "object") return true;
|
||||
const item = value as Record<string, unknown>;
|
||||
if ("project_id" in item && item.project_id !== expectedProjectId) return false;
|
||||
return Object.values(item).every((child) => hasOnlyExpectedProjectIds(child, expectedProjectId));
|
||||
}
|
||||
|
||||
function isRecognizedReadPayload(path: string, value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (path === "/context") {
|
||||
return typeof item.project_id === "string"
|
||||
&& item.protocol_version === "tjwater-peripheral-sample@1";
|
||||
}
|
||||
if (path === "/scenario") return isScenarioState(value);
|
||||
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")) {
|
||||
return isPageOf(value, isObservation) && typeof item.truncated === "boolean";
|
||||
}
|
||||
if (path === "/demo-data") {
|
||||
return item.origin === "synthetic"
|
||||
&& typeof item.generator_version === "string"
|
||||
&& Array.isArray(item.points)
|
||||
&& Array.isArray(item.observations);
|
||||
}
|
||||
if (path.startsWith("/work-orders")) {
|
||||
return isPageOf(value, (entry) => isManagedResult(entry) && "work_order_id" in entry);
|
||||
}
|
||||
if (path.startsWith("/test-data")) {
|
||||
return isPageOf(value, (entry) => isManagedResult(entry) && "record_id" in entry);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPageOf(
|
||||
value: unknown,
|
||||
predicate: (item: Record<string, unknown>) => boolean
|
||||
): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const items = (value as Record<string, unknown>).items;
|
||||
return Array.isArray(items) && items.every(
|
||||
(item) => Boolean(item && typeof item === "object" && predicate(item as Record<string, unknown>))
|
||||
);
|
||||
}
|
||||
|
||||
function isPoint(item: Record<string, unknown>) {
|
||||
return typeof item.point_id === "string" && typeof item.label === "string";
|
||||
}
|
||||
|
||||
function isAlert(item: Record<string, unknown>) {
|
||||
return typeof item.alert_id === "string" && typeof item.title === "string";
|
||||
}
|
||||
|
||||
function isObservation(item: Record<string, unknown>) {
|
||||
return typeof item.record_id === "string" && typeof item.point_id === "string";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T08:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T16:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: 8
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/config/env", () => ({ env: runtime }));
|
||||
|
||||
import { configuredHistoryWindow } from "./history-window";
|
||||
|
||||
describe("peripheral history window", () => {
|
||||
beforeEach(() => {
|
||||
runtime.TJWATER_PERIPHERAL_SAMPLE_DEMO_START = "2026-04-30T08:00:00Z";
|
||||
runtime.TJWATER_PERIPHERAL_SAMPLE_DEMO_END = "2026-04-30T16:00:00Z";
|
||||
runtime.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS = 8;
|
||||
});
|
||||
|
||||
it("uses the configured fixed window in demo mode", () => {
|
||||
expect(configuredHistoryWindow("demo", new Date("2030-01-01T00:00:00Z"))).toEqual({
|
||||
start: "2026-04-30T08:00",
|
||||
end: "2026-04-30T16:00"
|
||||
});
|
||||
});
|
||||
|
||||
it("derives a bounded UTC window from the clock in recent mode", () => {
|
||||
runtime.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS = 6;
|
||||
expect(configuredHistoryWindow("recent", new Date("2026-09-18T10:42:59Z"))).toEqual({
|
||||
start: "2026-09-18T04:42",
|
||||
end: "2026-09-18T10:42"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
|
||||
export type HistoryTimeMode = "demo" | "recent" | "custom";
|
||||
|
||||
export type HistoryWindow = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export function configuredHistoryWindow(
|
||||
mode: Exclude<HistoryTimeMode, "custom">,
|
||||
now = new Date()
|
||||
): HistoryWindow {
|
||||
if (mode === "demo") {
|
||||
return {
|
||||
start: toUtcMinute(new Date(env.TJWATER_PERIPHERAL_SAMPLE_DEMO_START)),
|
||||
end: toUtcMinute(new Date(env.TJWATER_PERIPHERAL_SAMPLE_DEMO_END))
|
||||
};
|
||||
}
|
||||
const end = new Date(Math.floor(now.getTime() / 60_000) * 60_000);
|
||||
const start = new Date(
|
||||
end.getTime() - env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS * 60 * 60 * 1000
|
||||
);
|
||||
return { start: toUtcMinute(start), end: toUtcMinute(end) };
|
||||
}
|
||||
|
||||
function toUtcMinute(value: Date) {
|
||||
return value.toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
Check,
|
||||
CircleDot,
|
||||
ClipboardList,
|
||||
Database,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wrench,
|
||||
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 { 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 { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import type {
|
||||
PeripheralContext,
|
||||
PeripheralSampleClient,
|
||||
PeripheralTestDataRecord,
|
||||
PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
type ResourceView = "orders" | "data";
|
||||
type Inventory = { workOrders: number; testData: number };
|
||||
|
||||
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange }: {
|
||||
client: PeripheralSampleClient;
|
||||
canWrite: boolean;
|
||||
capabilities: PeripheralContext["managed_resources"];
|
||||
onInventoryChange?: (inventory: Inventory) => 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 () => {
|
||||
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]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
useEffect(() => {
|
||||
onInventoryChange?.({ workOrders: orders.length, testData: records.length });
|
||||
}, [onInventoryChange, orders.length, records.length]);
|
||||
|
||||
const mutate = async (key: string, operation: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await operation();
|
||||
await refresh();
|
||||
} catch (nextError) {
|
||||
setError(message(nextError));
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <WorkbenchSection
|
||||
title="外围任务编排"
|
||||
description="一个任务包含多个子任务,每个子任务按派发、执行、复令推进。"
|
||||
icon={<GitBranch 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={<GitBranch 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} />
|
||||
: null
|
||||
) : (
|
||||
capabilities.test_data === "available"
|
||||
? <TestData records={records} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
|
||||
: null
|
||||
)}
|
||||
</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 }: ResourceProps & {
|
||||
orders: PeripheralWorkOrder[];
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
|
||||
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
|
||||
|
||||
const clear = () => {
|
||||
setEditing(null);
|
||||
setEditorOpen(false);
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setStatus("draft");
|
||||
setPriority("normal");
|
||||
};
|
||||
const create = () => {
|
||||
clear();
|
||||
setEditorOpen(true);
|
||||
};
|
||||
const edit = (item: PeripheralWorkOrder) => {
|
||||
setEditing(item);
|
||||
setEditorOpen(true);
|
||||
setTitle(item.title);
|
||||
setSummary(item.summary);
|
||||
setStatus(item.status);
|
||||
setPriority(item.priority);
|
||||
};
|
||||
|
||||
return <div>
|
||||
<div className="flex 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>
|
||||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增子任务</Button>
|
||||
</div>
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-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 });
|
||||
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}
|
||||
<TaskHierarchy
|
||||
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 TaskHierarchy({ orders, canWrite, busy, onEdit, onDelete }: {
|
||||
orders: PeripheralWorkOrder[];
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
onEdit: (item: PeripheralWorkOrder) => void;
|
||||
onDelete: (item: PeripheralWorkOrder) => void;
|
||||
}) {
|
||||
const completed = orders.filter((item) => item.status === "completed").length;
|
||||
const parentStatus = taskStatus(orders);
|
||||
return <section aria-label="任务层级" className="mt-3 overflow-hidden rounded-xl bg-slate-50/80 ring-1 ring-inset ring-slate-200">
|
||||
<header className="border-b border-cyan-100 bg-cyan-50/70 px-3.5 py-3 text-slate-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-xs ring-1 ring-inset ring-cyan-100"><ClipboardList size={16} /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-semibold text-cyan-700">任务</span><StatusBadge tone={parentStatus.tone}>{parentStatus.label}</StatusBadge></div>
|
||||
<h5 className="mt-1 truncate text-sm font-semibold">外围合成数据核查</h5>
|
||||
<p className="mt-1 text-[10px] text-slate-500"><span className="font-mono">DEMO-WO-001</span> · {orders.length} 个子任务 · {completed}/{orders.length} 完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{orders.length ? <ol className="relative space-y-2 p-3 before:absolute before:bottom-8 before:left-[25px] before:top-3 before:w-px before:bg-cyan-200">
|
||||
{orders.map((item, index) => <li key={item.work_order_id} className="relative pl-7">
|
||||
<span className="absolute left-0 top-3 z-10 grid size-5 place-items-center rounded-full bg-white text-[10px] font-semibold tabular-nums text-cyan-800 ring-1 ring-inset ring-cyan-300">{index + 1}</span>
|
||||
<SubtaskCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
|
||||
</li>)}
|
||||
</ol> : <div className="p-3"><WorkbenchEmptyState icon={<Wrench size={15} />} title="当前任务还没有子任务" description="新增子任务后,可在这里查看每项任务的派发、执行和复令状态。" /></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
item: PeripheralWorkOrder;
|
||||
index: number;
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return <article className="rounded-xl bg-white p-3 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
|
||||
<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></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>
|
||||
</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>
|
||||
<StageProgress 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>r{item.revision}</span>{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
type StageStatus = "done" | "current" | "pending" | "skipped";
|
||||
|
||||
function StageProgress({ status, title }: { status: PeripheralWorkOrder["status"]; title: string }) {
|
||||
const statuses = stageStatuses(status);
|
||||
const stages = ["派发", "执行", "复令"];
|
||||
return <div className="mt-3 rounded-lg bg-slate-50 px-2.5 py-2" aria-label={`子任务阶段 ${title}`}>
|
||||
<p className="mb-2 text-[10px] font-semibold text-slate-500">子任务阶段</p>
|
||||
<ol className="grid grid-cols-3">
|
||||
{stages.map((stage, index) => {
|
||||
const stageStatus = statuses[index];
|
||||
return <li key={stage} className="relative min-w-0 text-center">
|
||||
{index < stages.length - 1 ? <span className={cn("absolute left-[calc(50%+9px)] right-[calc(-50%+9px)] top-2 h-px", stageStatus === "done" ? "bg-cyan-400" : "bg-slate-300")} aria-hidden="true" /> : null}
|
||||
<span className={cn(
|
||||
"relative z-10 mx-auto grid size-4 place-items-center rounded-full text-[9px] font-semibold",
|
||||
stageStatus === "done" && "bg-cyan-700 text-white",
|
||||
stageStatus === "current" && "bg-white text-cyan-700 ring-2 ring-inset ring-cyan-500",
|
||||
stageStatus === "pending" && "bg-white text-slate-400 ring-1 ring-inset ring-slate-300",
|
||||
stageStatus === "skipped" && "bg-slate-200 text-slate-500"
|
||||
)}>{stageStatus === "done" ? <Check size={10} /> : stageStatus === "current" ? <CircleDot size={10} /> : stageStatus === "skipped" ? <Ban size={9} /> : index + 1}</span>
|
||||
<p className="mt-1 truncate text-[10px] font-semibold text-slate-700">{stage}</p>
|
||||
<p className={cn("truncate text-[9px]", stageStatus === "current" ? "font-semibold text-cyan-700" : stageStatus === "done" ? "text-cyan-700" : "text-slate-400")}>{stageStatusLabel(stageStatus)}</p>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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");
|
||||
};
|
||||
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-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-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 EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
|
||||
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-cyan-950">{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;
|
||||
};
|
||||
|
||||
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 taskStatus(orders: PeripheralWorkOrder[]): { label: string; tone: StatusTone } {
|
||||
if (!orders.length) return { label: "待编排", tone: "neutral" };
|
||||
if (orders.every((item) => item.status === "completed")) return { label: "已完成", tone: "success" };
|
||||
if (orders.every((item) => item.status === "cancelled")) return { label: "已取消", tone: "danger" };
|
||||
if (orders.some((item) => item.status === "in_progress")) return { label: "进行中", tone: "info" };
|
||||
return { label: "待处理", tone: "warning" };
|
||||
}
|
||||
function stageStatuses(status: PeripheralWorkOrder["status"]): [StageStatus, StageStatus, StageStatus] {
|
||||
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 stageStatusLabel(status: StageStatus) { return ({ done: "已完成", current: "当前阶段", pending: "待开始", skipped: "已跳过" } satisfies Record<StageStatus, string>)[status]; }
|
||||
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]; }
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CircleGauge, Database, History, LoaderCircle, RadioTower, 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 { ManagedTestResources } from "./managed-test-resources";
|
||||
import { configuredHistoryWindow, type HistoryTimeMode } from "./history-window";
|
||||
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
PeripheralSampleError,
|
||||
type PeripheralContext,
|
||||
type PeripheralObservations,
|
||||
type PeripheralPointPage,
|
||||
type PeripheralScenario
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
}) {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [receiptRequestId, setReceiptRequestId] = useState("");
|
||||
const [inventory, setInventory] = useState({ workOrders: 0, testData: 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 || "");
|
||||
}
|
||||
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]);
|
||||
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)]">
|
||||
<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}
|
||||
</div>
|
||||
<SampleOverview
|
||||
configured={client.configured}
|
||||
context={context}
|
||||
points={points.length}
|
||||
scenario={scenario}
|
||||
resources={inventory.workOrders + inventory.testData}
|
||||
/>
|
||||
{context ? (
|
||||
<div className="mt-3 grid items-start gap-3 md:grid-cols-[minmax(250px,0.78fr)_minmax(390px,1.22fr)]">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<WorkbenchSection title="连接与演练场景" description={monitoring}
|
||||
icon={<RadioTower size={15} />} tone="teal" meta={<StatusBadge tone={context.can_write ? "info" : "neutral"}>{context.can_write ? "可写样板" : "只读"}</StatusBadge>}>
|
||||
<FieldSelect label="场景" value={scenario} disabled={!context.can_write || Boolean(busy)} options={context.scenarios.map((item) => ({ value: item, label: scenarioLabel(item) }))} onValueChange={async (value) => {
|
||||
const next = value as PeripheralScenario;
|
||||
setBusy("scenario"); setError(null);
|
||||
try { setScenario((await client.setScenario(next)).scenario); await refresh(); }
|
||||
catch (nextError) { setError(errorMessage(nextError)); }
|
||||
finally { setBusy(null); }
|
||||
}} />
|
||||
</WorkbenchSection>
|
||||
|
||||
<WorkbenchSection title="历史监测回放"
|
||||
description={`UTC 历史窗口 · ${historyMode === "demo" ? "固定演示时段" : historyMode === "recent" ? `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` : "自定义时段"}。仅查询外围数据。`}
|
||||
icon={<History size={15} />} tone="teal">
|
||||
<FieldSelect label="时间模式" value={historyMode} options={[
|
||||
{ value: "demo", label: "演示历史窗口" },
|
||||
{ value: "recent", label: `最近 ${env.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS} 小时` },
|
||||
{ value: "custom", label: "自定义窗口" }
|
||||
]} onValueChange={(value) => {
|
||||
const next = value as HistoryTimeMode;
|
||||
setHistoryMode(next); setObservations(null);
|
||||
if (next !== "custom") {
|
||||
const window = configuredHistoryWindow(next);
|
||||
setHistoryStart(window.start); setHistoryEnd(window.end);
|
||||
}
|
||||
}} />
|
||||
<FieldSelect className="mt-3" label="测点" value={pointId} placeholder="选择测点" options={points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} onValueChange={(value) => { setPointId(value); setObservations(null); }} />
|
||||
<div className="mt-2 grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1">
|
||||
<DateTimeField label="开始(UTC)" value={historyStart} onChange={(value) => { setHistoryStart(value); setHistoryMode("custom"); setObservations(null); }} />
|
||||
<DateTimeField label="结束(UTC)" value={historyEnd} onChange={(value) => { setHistoryEnd(value); setHistoryMode("custom"); setObservations(null); }} />
|
||||
</div>
|
||||
<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">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
onInventoryChange={setInventory}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 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]; }
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Database, LoaderCircle } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
type PeripheralDemoDataset
|
||||
} from "./client";
|
||||
|
||||
export function SyntheticDemoSection({ client }: { client: PeripheralSampleClient }) {
|
||||
const [dataset, setDataset] = useState<PeripheralDemoDataset | null>(null);
|
||||
const [pointId, setPointId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const observations = useMemo(
|
||||
() => dataset?.observations.filter((item) => item.point_id === pointId) ?? [],
|
||||
[dataset, pointId]
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await client.getDemoData();
|
||||
setDataset(next);
|
||||
setPointId((current) => current || next.points[0]?.point_id || "");
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "合成演示样本读取失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
label={<span className="flex min-w-0 items-center gap-3"><span className="grid size-7 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-800"><Database size={15} /></span><span className="min-w-0"><span className="block text-sm font-semibold text-slate-900">合成演示样本</span><span className="block truncate text-xs font-normal text-slate-500">固定种子 · 虚拟资产 · 非控制指令</span></span></span>}
|
||||
className="overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&>summary]:min-h-14 [&>summary]:py-3"
|
||||
>
|
||||
<div className="-mx-3.5 border-t border-slate-200/80 p-3.5">
|
||||
<p className="text-xs leading-5 text-slate-600">样本由外围服务从持久化数据集读取,浏览器不会生成或写入数据。所有资产、告警和运行事件均用于演示,不代表现场状态,也不是控制指令。</p>
|
||||
<Button variant="outline" className="mt-3 w-full" disabled={loading} onClick={() => void load()}>
|
||||
{loading ? <LoaderCircle className="animate-spin" /> : <Database />}
|
||||
{dataset ? "重新读取固定样本" : "手动读取合成样本"}
|
||||
</Button>
|
||||
{error ? <p className="mt-2 rounded-lg bg-rose-50 p-2.5 text-xs leading-5 text-rose-900 ring-1 ring-inset ring-rose-200">{error}。真实历史回放与核查管理不受影响。</p> : null}
|
||||
{dataset ? <DemoDatasetView dataset={dataset} pointId={pointId} onPointChange={setPointId} observations={observations} /> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoDatasetView({ dataset, pointId, onPointChange, observations }: {
|
||||
dataset: PeripheralDemoDataset;
|
||||
pointId: string;
|
||||
onPointChange: (value: string) => void;
|
||||
observations: PeripheralDemoDataset["observations"];
|
||||
}) {
|
||||
return <div className="mt-3 space-y-3">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50/65 p-3 text-xs text-cyan-950">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<Meta label="性质" value="合成演示" />
|
||||
<Meta label="生成器" value={dataset.generator_version} />
|
||||
<Meta label="固定种子" value={dataset.seed} />
|
||||
<Meta label="来源修订" value={dataset.source_revision} />
|
||||
</div>
|
||||
<p className="mt-2 break-words leading-5">固定窗口:{formatTimestamp(dataset.window.start)} 至 {formatTimestamp(dataset.window.end)}</p>
|
||||
<p className="mt-1 leading-5">摘要:{dataset.points.length} 个虚拟测点,{dataset.observations.length} 条读数,{dataset.device_states.length} 条状态,{dataset.alerts.length} 条告警,{dataset.operations.length} 条运行事件。</p>
|
||||
</div>
|
||||
<FieldSelect label="虚拟测点" value={pointId} onValueChange={onPointChange} options={dataset.points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} />
|
||||
<div className="max-h-48 overflow-auto rounded-lg bg-white ring-1 ring-slate-200">
|
||||
<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.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{formatTimestamp(item.observed_at)}</td><td className={cn("whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums", item.value === null && "text-amber-700")}>{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
<DemoList title={`设备状态 ${dataset.device_states.length}`} items={dataset.device_states.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${item.state}`, detail: `${item.asset_type} · ${formatTimestamp(item.observed_at)}` }))} />
|
||||
<DemoList title={`演示告警 ${dataset.alerts.length}`} items={dataset.alerts.map((item) => ({ id: item.alert_id, title: `${item.asset_id} · ${item.code}`, detail: `${item.message} · ${formatTimestamp(item.occurred_at)}` }))} />
|
||||
<DemoList title={`运行事件 ${dataset.operations.length}`} items={dataset.operations.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${operationLabel(item.operation)}`, detail: `${item.note} · ${formatTimestamp(item.occurred_at)}` }))} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) { return <div><span className="block text-[11px] text-cyan-700">{label}</span><span className="mt-0.5 block break-all font-medium">{value}</span></div>; }
|
||||
function DemoList({ title, items }: { title: string; items: Array<{ id: string; title: string; detail: string }> }) { return <Disclosure label={title} className="rounded-lg bg-white px-3 py-2 ring-1 ring-slate-200"><div className="mt-2 space-y-2">{items.map((item) => <div key={item.id} className="border-t border-slate-100 pt-2 text-[11px]"><p className="font-medium text-slate-700">{item.title}</p><p className="mt-1 leading-5 text-slate-500">{item.detail}</p></div>)}</div></Disclosure>; }
|
||||
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC"); }
|
||||
function operationLabel(value: PeripheralDemoDataset["operations"][number]["operation"]) { return ({ pump_start: "泵启动", pump_stop: "泵停止", valve_open: "阀门开启", valve_close: "阀门关闭" } satisfies Record<typeof value, string>)[value]; }
|
||||
Reference in New Issue
Block a user