feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchElementGisProperties } from "./gis-properties-client";
describe("GIS properties client", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads validated attributes for the selected project element", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
element_type: "pipes",
element_id: "P/117",
groups: [{
dataset_key: "fx_pipe_inventory_20200811",
dataset_title: "奉贤 DN100 及以上管网台账",
source_date: "2020-08-11",
layer_key: "fx_20200811_dn100_pipes",
layer_name: "DN100 及以上管线台账",
source_fid: 117,
match_method: "geometry",
match_distance_m: 0.12,
attributes: { PIPE_D: 300, MATERIAL: "DI" }
}]
}), { status: 200, headers: { "Content-Type": "application/json" } }));
const groups = await fetchElementGisProperties(
"pipes",
"P/117",
"project-117",
async () => "read-token"
);
expect(groups[0].attributes).toEqual({ PIPE_D: 300, MATERIAL: "DI" });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/elements/pipes/P%2F117/gis-properties");
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-117");
});
});
@@ -0,0 +1,114 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
dataset_title: z.string().min(1),
source_date: z.string().date(),
layer_key: z.string().min(1),
layer_name: z.string().min(1),
source_fid: z.number().int(),
match_method: z.enum(["node_pair", "geometry", "source"]),
match_distance_m: z.number().finite().nonnegative().nullable(),
attributes: z.record(z.unknown())
});
const elementGisPropertiesSchema = z.object({
element_type: z.enum([
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada"
]),
element_id: z.string().min(1),
groups: z.array(gisAttributeGroupSchema)
});
const sourceGisFeaturePropertiesSchema = z.object({
layer_key: z.string().min(1),
source_fid: z.number().int().nonnegative(),
group: gisAttributeGroupSchema
});
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
elementId: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
}
const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
}
return result.groups;
}
export async function fetchSourceGisFeatureProperties(
layerKey: string,
sourceFid: number,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
"features",
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
}
const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致");
}
return [result.group];
}
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchProjectPresentation,
findOwnedWorkbenchProject,
parseProjectMapExtent
} from "./project-client";
describe("workbench project client", () => {
afterEach(() => vi.unstubAllGlobals());
it("selects the active project owned by the configured GeoServer workspace", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
items: [
{ project_id: "inactive", gs_workspace: "tjwater_next", status: "disabled" },
{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }
],
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const result = await findOwnedWorkbenchProject(async () => "token");
expect(result.project.project_id).toBe("supply");
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBeNull();
});
it("does not infer a project when the authorized list has no workspace match", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
items: [], total: 0, limit: 1000, offset: 0
}), { status: 200 })));
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("loads the authoritative project extent and access identity independently", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
if (path === "/api/v1/projects") {
return new Response(JSON.stringify({
items: [{
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
status: "active",
project_role: "member"
}],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 });
}
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBe("supply");
if (path === "/api/v1/projects/current/metadata") {
return new Response(JSON.stringify({
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
map_extent: { bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23], zoom: 13 },
status: "active",
project_role: "member"
}), { status: 200 });
}
return new Response(JSON.stringify({
user_id: "user-1",
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "supply",
project_role: "member",
permissions: ["webgis.view"]
}), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const presentation = await fetchWorkbenchProjectPresentation(async () => "token");
expect(presentation).toMatchObject({
projectId: "supply",
projectName: "北区供水项目",
username: "north.operator",
projectRole: "member",
metadataAvailable: true,
accessContextAvailable: true,
networkView: {
bbox3857: [13358338.895, 3503549.844, 13369470.85, 3516410.23]
}
});
});
it("rejects malformed or reversed project extents without losing the fallback path", () => {
expect(parseProjectMapExtent({ bbox: [10, 20, 5, 30] })).toBeNull();
expect(parseProjectMapExtent({ bbox: [0, 0, 30_000_000, 1] })).toBeNull();
expect(parseProjectMapExtent({ xmin: 1, ymin: 2, xmax: 3, ymax: 4 })).toEqual({
bbox3857: [1, 2, 3, 4]
});
});
});
@@ -0,0 +1,196 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
const projectSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1).optional(),
code: z.string().trim().min(1).optional(),
gs_workspace: z.string().min(1),
status: z.string(),
project_role: z.string().trim().min(1).optional()
});
const projectPageSchema = z.object({
items: z.array(projectSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
export type WorkbenchProject = z.infer<typeof projectSchema>;
const projectMetadataSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1),
code: z.string().trim().min(1),
gs_workspace: z.string().trim().min(1),
map_extent: z.unknown().nullable().optional(),
status: z.string(),
project_role: z.string().trim().min(1)
});
const accessContextSchema = z.object({
user_id: z.string().min(1),
username: z.string().trim().min(1),
system_role: z.string().trim().min(1),
is_system_admin: z.boolean(),
project_id: z.string().min(1).nullable().optional(),
project_role: z.string().trim().min(1).nullable().optional(),
permissions: z.array(z.string())
});
const bboxSchema = z.tuple([
z.number().finite(),
z.number().finite(),
z.number().finite(),
z.number().finite()
]);
const keyedExtentSchema = z.object({
xmin: z.number().finite(),
ymin: z.number().finite(),
xmax: z.number().finite(),
ymax: z.number().finite()
});
export type WorkbenchProjectPresentation = {
projectId: string;
projectName: string | null;
username: string | null;
projectRole: string | null;
systemRole: string | null;
isSystemAdmin: boolean;
networkView: NetworkViewParams | null;
metadataAvailable: boolean;
accessContextAvailable: boolean;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const projects = projectPageSchema.parse(payload);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return { project, accessToken };
}
export async function fetchWorkbenchProjectPresentation(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchProjectPresentation> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const headers = createWorkbenchHeaders(accessToken, project.project_id);
const [metadataResult, accessResult] = await Promise.allSettled([
fetchProjectMetadata(project, headers, signal),
fetchAccessContext(project, headers, signal)
]);
const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : null;
const accessContext = accessResult.status === "fulfilled" ? accessResult.value : null;
return {
projectId: project.project_id,
projectName: metadata?.name ?? project.name ?? null,
username: accessContext?.username ?? null,
projectRole:
accessContext?.project_role
?? metadata?.project_role
?? project.project_role
?? null,
systemRole: accessContext?.system_role ?? null,
isSystemAdmin: accessContext?.is_system_admin ?? false,
networkView: parseProjectMapExtent(metadata?.map_extent),
metadataAvailable: metadata !== null,
accessContextAvailable: accessContext !== null
};
}
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
let bbox: z.infer<typeof bboxSchema> | null = null;
if (value && typeof value === "object" && "bbox" in value) {
const parsed = bboxSchema.safeParse(value.bbox);
if (parsed.success) bbox = parsed.data;
} else {
const parsed = keyedExtentSchema.safeParse(value);
if (parsed.success) {
bbox = [parsed.data.xmin, parsed.data.ymin, parsed.data.xmax, parsed.data.ymax];
}
}
if (!bbox || bbox[0] >= bbox[2] || bbox[1] >= bbox[3]) return null;
const webMercatorLimit = 20_037_508.35;
if (bbox.some((coordinate) => Math.abs(coordinate) > webMercatorLimit)) return null;
return { bbox3857: bbox };
}
async function fetchProjectMetadata(
project: WorkbenchProject,
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
}
const metadata = projectMetadataSchema.parse(payload);
if (metadata.project_id !== project.project_id) {
throw new Error("项目元数据与授权项目不一致");
}
return metadata;
}
async function fetchAccessContext(
project: WorkbenchProject,
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
}
const accessContext = accessContextSchema.parse(payload);
if (accessContext.project_id !== project.project_id) {
throw new Error("访问上下文与授权项目不一致");
}
return accessContext;
}
export function createWorkbenchHeaders(
accessToken: string | null | undefined,
projectId?: string,
json = false
) {
const headers = new Headers({ Accept: "application/json" });
if (json) headers.set("Content-Type", "application/json");
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers.set("X-Project-Id", projectId);
return headers;
}
export function getServerError(payload: unknown, status: number, operation: string) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String(payload.detail)
: `HTTP ${status}`;
return `${operation}失败:${detail}`;
}
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchRealtimePipeFlows } from "./realtime-flow-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
describe("realtime pipe flow client", () => {
afterEach(() => vi.unstubAllGlobals());
it("keeps the latest finite velocity for each backend pipe", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ time: "2026-09-17T02:25:00Z", link_id: "P1", velocity: -0.3 },
{ time: "2026-09-17T02:29:00Z", link_id: "P1", velocity: 0.4 },
{ time: "2026-09-17T02:28:00Z", link_id: "P2", velocity: 0 },
{ time: "2026-09-17T02:29:00Z", link_id: "P3", velocity: null }
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchRealtimePipeFlows(
vi.fn().mockResolvedValue("access-token"),
undefined,
new Date("2026-09-17T02:30:00Z")
);
expect(snapshot).toEqual({
observedAt: "2026-09-17T02:29:00Z",
records: [
{ pipeId: "P1", velocity: 0.4 },
{ pipeId: "P2", velocity: 0 }
]
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"start_time=2026-09-17T02%3A15%3A00.000Z"
);
});
it("rejects malformed rows so the caller can keep the mock fallback", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ time: "2026-09-17T02:29:00Z", link_id: "P1", velocity: "fast" }
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchRealtimePipeFlows()).rejects.toThrow();
});
});
@@ -0,0 +1,65 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }),
link_id: z.union([z.string().trim().min(1), z.number()]),
velocity: z.number().finite().nullable()
});
const realtimeLinksSchema = z.array(realtimeLinkSchema);
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = {
observedAt: string;
records: PipeFlowCalculation[];
};
export async function fetchRealtimePipeFlows(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal,
now = new Date()
): Promise<RealtimePipeFlowSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString()
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "实时管道流速请求"));
}
const rows = realtimeLinksSchema.parse(payload);
const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>();
rows.forEach((row) => {
if (row.velocity === null) return;
const pipeId = String(row.link_id);
const current = latestByPipe.get(pipeId);
if (!current || Date.parse(row.time) >= Date.parse(current.time)) {
latestByPipe.set(pipeId, row);
}
});
const latestRows = [...latestByPipe.values()];
return {
observedAt: latestRows.reduce(
(latest, row) => Date.parse(row.time) > Date.parse(latest) ? row.time : latest,
latestRows[0]?.time ?? now.toISOString()
),
records: latestRows.map((row) => ({
pipeId: row.link_id,
velocity: row.velocity as number
}))
};
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchScadaDeviceCollection } from "./scada-client";
import { fetchScadaDeviceCollection, fetchScadaSnapshot } from "./scada-client";
const device = {
device_id: "SCADA-1",
@@ -7,6 +7,7 @@ const device = {
node_id: "J-1",
link_id: null,
api_query_id: "pressure-query",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
@@ -35,7 +36,15 @@ describe("SCADA API client", () => {
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{
time: "2026-09-17T02:29:00Z",
device_id: "SCADA-1",
monitored_value: 0.31,
cleaned_value: 0.3
}
]), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("access-token");
@@ -45,17 +54,57 @@ describe("SCADA API client", () => {
expect.objectContaining({
id: "SCADA-1",
geometry: { type: "Point", coordinates: [121.5, 30.9] },
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
properties: expect.objectContaining({
device_id: "SCADA-1",
node_id: "J-1",
latest_value: 0.31,
latest_value_with_unit: "0.31 MPa",
latest_observed_at: "2026-09-17T02:29:00Z",
latest_value_source: "monitored_value"
})
})
]);
expect(getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(3);
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain("/api/v1/timeseries/scada-readings?");
});
it("keeps real device metadata when realtime readings are unavailable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [device],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "TimescaleDB unavailable" }), {
status: 503
}));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchScadaSnapshot(
undefined,
undefined,
new Date("2026-09-17T02:30:00Z")
);
expect(snapshot.telemetryStatus).toBe("unavailable");
expect(snapshot.observedAt).toBeNull();
expect(snapshot.collection.features).toHaveLength(1);
expect(snapshot.collection.features[0]?.properties).not.toHaveProperty("latest_value");
});
it("does not query devices when no active project matches the workspace", async () => {
+123 -35
View File
@@ -1,20 +1,8 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
const projectSchema = z.object({
project_id: z.string().min(1),
gs_workspace: z.string().min(1),
status: z.string()
});
const projectPageSchema = z.object({
items: z.array(projectSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
@@ -22,6 +10,7 @@ const scadaDeviceSchema = z.object({
node_id: z.string().nullable(),
link_id: z.string().nullable(),
api_query_id: z.string().nullable(),
measurement_unit: z.string(),
transmission_mode: z.string(),
transmission_frequency: z.string(),
reliability: z.number().int().nullable(),
@@ -38,24 +27,41 @@ const scadaDevicePageSchema = z.object({
offset: z.number().int().nonnegative()
});
const scadaReadingSchema = z.object({
time: z.string().datetime({ offset: true }),
device_id: z.string().trim().min(1),
monitored_value: z.number().finite().nullable(),
cleaned_value: z.number().finite().nullable()
});
const scadaReadingsSchema = z.array(scadaReadingSchema);
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
export type ScadaSnapshot = {
collection: FeatureCollection<Point, ScadaDevice>;
observedAt: string | null;
observedDeviceCount: number;
telemetryStatus: "ready" | "empty" | "unavailable";
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100;
export async function fetchScadaDeviceCollection(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<FeatureCollection<Point, ScadaDevice>> {
const accessToken = await getAccessToken?.();
const projects = projectPageSchema.parse(
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) {
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
}
return (await fetchScadaSnapshot(getAccessToken, signal)).collection;
}
export async function fetchScadaSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal,
now = new Date()
): Promise<ScadaSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const page = scadaDevicePageSchema.parse(
await requestJson(
@@ -69,14 +75,49 @@ export async function fetchScadaDeviceCollection(
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
}
const readings = await fetchLatestReadings(
page.items.map((device) => device.device_id),
accessToken,
project.project_id,
signal,
now
);
const latestByDevice = new Map<string, z.infer<typeof scadaReadingSchema>>();
readings.rows.forEach((reading) => {
if (reading.monitored_value === null) return;
const current = latestByDevice.get(reading.device_id);
if (!current || Date.parse(reading.time) >= Date.parse(current.time)) {
latestByDevice.set(reading.device_id, reading);
}
});
const observedAt = [...latestByDevice.values()].reduce<string | null>(
(latest, reading) => !latest || Date.parse(reading.time) > Date.parse(latest)
? reading.time
: latest,
null
);
return {
type: "FeatureCollection",
features: page.items.flatMap(toScadaFeature)
collection: {
type: "FeatureCollection",
features: page.items.flatMap((device) => toScadaFeature(device, latestByDevice.get(device.device_id)))
},
observedAt,
observedDeviceCount: latestByDevice.size,
telemetryStatus: readings.status === "unavailable"
? "unavailable"
: latestByDevice.size > 0
? "ready"
: "empty"
};
}
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
function toScadaFeature(
device: ScadaDevice,
reading?: z.infer<typeof scadaReadingSchema>
): Array<Feature<Point, ScadaDevice>> {
if (device.longitude === null || device.latitude === null) return [];
const latestValue = reading?.monitored_value;
return [{
type: "Feature",
id: device.device_id,
@@ -84,26 +125,73 @@ function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>>
type: "Point",
coordinates: [device.longitude, device.latitude]
},
properties: device
properties: {
...device,
...(reading && latestValue !== null
? {
latest_value: latestValue,
latest_value_with_unit: `${latestValue} ${device.measurement_unit}`.trim(),
latest_observed_at: reading.time,
latest_value_source: "monitored_value"
}
: {})
}
}];
}
async function fetchLatestReadings(
deviceIds: string[],
accessToken: string | null | undefined,
projectId: string,
signal: AbortSignal | undefined,
now: Date
): Promise<{
rows: Array<z.infer<typeof scadaReadingSchema>>;
status: "ready" | "unavailable";
}> {
if (deviceIds.length === 0) return { rows: [], status: "ready" };
const batches = chunk(deviceIds, SCADA_READING_BATCH_SIZE);
try {
const responses = await Promise.all(batches.map(async (batch) => {
const query = new URLSearchParams({
start_time: new Date(now.getTime() - SCADA_LOOKBACK_MS).toISOString(),
end_time: now.toISOString(),
device_ids: batch.join(",")
});
return scadaReadingsSchema.parse(await requestJson(
`/api/v1/timeseries/scada-readings?${query.toString()}`,
accessToken,
projectId,
signal
));
}));
return { rows: responses.flat(), status: "ready" };
} catch (error) {
if (signal?.aborted) throw error;
return { rows: [], status: "unavailable" };
}
}
function chunk<T>(values: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size));
}
return chunks;
}
async function requestJson(
path: string,
accessToken: string | null | undefined,
projectId: string | undefined,
signal: AbortSignal | undefined
) {
const headers = new Headers({ Accept: "application/json" });
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers.set("X-Project-Id", projectId);
const headers = createWorkbenchHeaders(accessToken, projectId);
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String(payload.detail)
: `HTTP ${response.status}`;
throw new Error(`SCADA API请求失败:${detail}`);
throw new Error(getServerError(payload, response.status, "SCADA API请求"));
}
return payload;
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWorkbenchAnalysisHistory } from "./workbench-analysis-history-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
describe("workbench analysis history client", () => {
afterEach(() => vi.unstubAllGlobals());
it("maps a bounded page and binds it to the selected project", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{
run_id: "run-1",
name: "北区传感器布点",
run_type: "sensor_placement",
run_type_label: "传感器布点",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:30:00Z",
started_at: "2026-09-17T02:20:00Z",
has_results: true
}],
limit: 10,
offset: 20,
next_offset: 30
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const page = await fetchWorkbenchAnalysisHistory({
limit: 10,
offset: 20,
getAccessToken: vi.fn().mockResolvedValue("access-token")
});
expect(page).toEqual({
items: [expect.objectContaining({
runId: "run-1",
runTypeLabel: "传感器布点",
hasResults: true
})],
nextOffset: 30
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"/api/v1/workbench/analysis-runs?limit=10&offset=20"
);
const headers = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer access-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("rejects an untyped history payload", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ run_id: "run-1", name: "missing fields" }],
limit: 10,
offset: 0,
next_offset: null
}), { status: 200 })));
await expect(fetchWorkbenchAnalysisHistory()).rejects.toThrow();
});
});
@@ -0,0 +1,97 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
status: z.string().trim().min(1),
status_label: z.string().trim().min(1),
created_by: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
started_at: z.string().datetime({ offset: true }),
has_results: z.boolean()
});
const analysisHistoryPageSchema = z.object({
items: z.array(analysisHistoryItemSchema),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable()
});
export type WorkbenchAnalysisHistoryItem = {
runId: string;
name: string;
runType: string;
runTypeLabel: string;
status: string;
statusLabel: string;
createdBy: string;
createdAt: string;
startedAt: string;
hasResults: boolean;
};
export type WorkbenchAnalysisHistoryPage = {
items: WorkbenchAnalysisHistoryItem[];
nextOffset: number | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory(
options: {
limit?: number;
offset?: number;
getAccessToken?: AccessTokenProvider;
signal?: AbortSignal;
} = {}
): Promise<WorkbenchAnalysisHistoryPage> {
const limit = options.limit ?? 10;
const offset = options.offset ?? 0;
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error("分析成果历史每页数量必须在 1 到 100 之间");
}
if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效");
}
const { project, accessToken } = await findOwnedWorkbenchProject(
options.getAccessToken,
options.signal
);
const search = new URLSearchParams({
limit: String(limit),
offset: String(offset)
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal: options.signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
}
const page = analysisHistoryPageSchema.parse(payload);
return {
items: page.items.map((item) => ({
runId: item.run_id,
name: item.name,
runType: item.run_type,
runTypeLabel: item.run_type_label,
status: item.status,
statusLabel: item.status_label,
createdBy: item.created_by,
createdAt: item.created_at,
startedAt: item.started_at,
hasResults: item.has_results
})),
nextOffset: page.next_offset
};
}
@@ -0,0 +1,405 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchAnalysisRun,
fetchWorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
const runtimeSnapshot = {
availability: "ready",
source: "scheduled_tasks",
scenario_availability: "ready",
artifact_availability: "ready",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "ready",
generated_at: "2026-09-17T02:30:00Z",
conditions: [{
id: "schedule-run-1",
task_id: "backend-schedule",
session_id: "scheduled-condition-schedule-run-1",
title: "演示 合成低压调查",
summary: "来自后端持久化 Action Run 的 演示 沙箱测试状态。",
status: "error",
risk_level: "critical",
scheduled_at: "2026-09-17T02:20:00Z",
updated_at: "2026-09-17T02:25:00Z",
detail: "演示 测试运行状态:已失败。",
recommendation: "请检查失败步骤。",
evidence: ["运行状态:failed"],
model_name: "TJWater scheduler"
}],
alerts: [{
id: "scheduled-condition-alert-schedule-run-1",
condition_id: "schedule-run-1",
source: {
task_type: "analysis_task",
task_id: "backend-schedule",
run_id: "schedule-run-1"
},
title: "演示 测试待复核:演示 合成低压调查",
description: "运行失败。",
occurred_at: "2026-09-17T02:25:00Z",
severity: "critical"
}],
scenarios: [{
id: "analysis-run-analysis-1",
run_id: "analysis-1",
name: "北区爆管定位",
description: "爆管定位 · 已完成 · 创建人 alice",
status: "review",
run_type: "burst_location",
run_type_label: "爆管定位",
run_status: "completed",
created_at: "2026-09-17T02:20:00Z"
}],
latest_analysis: {
run_id: "analysis-1",
name: "北区爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [{
result_type: "burst_location",
created_at: "2026-09-17T02:20:00Z",
facts: [{ label: "关联管段", value: "P-42" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "后端爆管定位" },
geometry: { type: "Point", coordinates: [121.5, 30.9] }
}]
}
},
work_orders: []
};
describe("workbench runtime API client", () => {
afterEach(() => vi.unstubAllGlobals());
it("resolves the project and maps a persisted scheduled condition", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(runtimeSnapshot), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot(
vi.fn().mockResolvedValue("access-token")
);
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.conditions).toEqual([
expect.objectContaining({
id: "schedule-run-1",
kind: "condition",
taskId: "backend-schedule",
status: "error",
modelName: "TJWater scheduler"
})
]);
expect(snapshot.alerts[0]).toEqual(expect.objectContaining({
conditionId: "schedule-run-1",
id: "scheduled-condition-alert-schedule-run-1",
title: "演示 测试待复核:演示 合成低压调查",
source: {
taskType: "analysis_task",
taskId: "backend-schedule",
runId: "schedule-run-1"
}
}));
expect(snapshot.scenarios[0]).toEqual(expect.objectContaining({
id: "analysis-run-analysis-1",
runId: "analysis-1",
runType: "burst_location"
}));
expect(snapshot.latestAnalysisArtifact).toEqual(expect.objectContaining({
id: "persisted-analysis-analysis-1",
title: "北区爆管定位",
confidence: "后端未提供",
mapRelation: "required",
spatialOverlay: expect.objectContaining({ type: "FeatureCollection" }),
sources: ["项目 BizDB · analysis.runs", "项目 BizDB · analysis.results"]
}));
expect(snapshot.latestAnalysisArtifact?.blocks).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "data-table",
rows: [expect.objectContaining({ facts: "关联管段:P-42" })]
})
]));
expect(snapshot.simulationOverlay?.features).toHaveLength(1);
const runtimeHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(runtimeHeaders.get("Authorization")).toBe("Bearer access-token");
expect(runtimeHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/workbench/runtime");
});
it("keeps legacy analysis conditions out of the operational task feed", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
source: "analysis_runs",
conditions: [{
id: "analysis-run-analysis-1",
task_id: "backend-analysis",
session_id: "scheduled-condition-analysis-run-analysis-1",
title: "北区爆管定位",
summary: "来自旧版后端的分析运行投影。",
status: "completed",
risk_level: "normal",
scheduled_at: "2026-09-17T02:10:00Z",
updated_at: "2026-09-17T02:20:00Z",
detail: "分析结果已完成。",
recommendation: null,
evidence: ["运行 ID:analysis-1"],
model_name: "TJWater analysis runtime"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.source).toBe("none");
expect(snapshot.conditions).toEqual([]);
expect(snapshot.latestAnalysisArtifact).toEqual(expect.objectContaining({
id: "persisted-analysis-analysis-1",
title: "北区爆管定位"
}));
expect(snapshot.scenarios).toHaveLength(1);
});
it("rejects an untyped runtime payload instead of treating it as real data", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...runtimeSnapshot, source: "production" }), {
status: 200
}));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchRuntimeSnapshot()).rejects.toThrow();
});
it("loads the requested analysis run instead of reusing the latest result", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
const selectedAnalysis = {
...runtimeSnapshot.latest_analysis,
run_id: runId,
name: "历史爆管定位",
results: [{
result_type: "burst_location",
created_at: "2026-09-17T01:20:00Z",
facts: [{ label: "关联管段", value: "P-older" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(selectedAnalysis), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const projection = await fetchWorkbenchAnalysisRun(
runId,
vi.fn().mockResolvedValue("access-token")
);
expect(projection.runId).toBe(runId);
expect(projection.artifact).toEqual(expect.objectContaining({
id: `persisted-analysis-${runId}`,
title: "历史爆管定位"
}));
expect(projection.artifact.blocks).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "data-table",
rows: [expect.objectContaining({ facts: "关联管段:P-older" })]
})
]));
expect(projection.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
`/api/v1/workbench/analysis-runs/${runId}`
);
const headers = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer access-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("drops invalid backend map geometry without discarding other real sections", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
latest_analysis: {
...runtimeSnapshot.latest_analysis,
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: ["east", "north"] }
}]
}
}
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.scenarios).toHaveLength(1);
expect(snapshot.simulationOverlay).toBeNull();
expect(snapshot.latestAnalysisArtifact?.mapRelation).toBe("none");
expect(snapshot.latestAnalysisArtifact).not.toHaveProperty("spatialOverlay");
});
it("maps provisioned backend work orders without using the demo shape", async () => {
const backendWorkOrder = {
id: "wo-1",
code: "WO-20260917-001",
scheduled_at: "2026-09-17T03:00:00Z",
title: "北区阀门现场复核",
summary: "复核阀门开度并回传。",
status: "completed",
risk_level: "attention",
updated_at: "2026-09-17T02:30:00Z",
detail: "来自正式工单同步适配器。",
recommendation: "完成后复核压力。",
duration_minutes: 45,
source: "生产工单系统",
dispatcher: "调度中心",
priority: "urgent",
subtasks: [{
id: "wo-1-subtask-1",
title: "阀门开度复核",
summary: "现场复核阀门开度。",
status: "completed",
scheduled_at: "2026-09-17T03:00:00Z",
location: "北区阀门 V-1",
assignee: "北区班组",
duration_minutes: 45,
reply_window_minutes: 20,
stages: [
{
id: "dispatch",
sequence: 1,
title: "派发",
status: "done",
owner: "调度中心",
time_label: "10:50",
description: "已派发。"
},
{
id: "execution",
sequence: 2,
title: "执行中",
status: "done",
owner: "北区班组",
time_label: "11:00—11:45",
description: "现场执行中。"
},
{
id: "reply",
sequence: 3,
title: "待复令",
status: "done",
owner: "北区班组",
time_label: "11:45—12:05",
description: "等待复令。"
}
],
reply_requirements: ["回传阀门开度"]
}]
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
work_order_availability: "ready",
work_orders: [backendWorkOrder]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.workOrderAvailability).toBe("ready");
expect(snapshot.workOrders).toEqual([expect.objectContaining({
id: "wo-1",
kind: "work_order",
code: "WO-20260917-001",
status: "completed",
source: "生产工单系统",
subtasks: expect.arrayContaining([
expect.objectContaining({
id: "wo-1-subtask-1",
stages: expect.arrayContaining([
expect.objectContaining({ id: "execution", sequence: 2, timeLabel: "11:00—11:45" })
])
})
])
})]);
});
it("maps a provisioned backend alert without inventing a condition link", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
...runtimeSnapshot,
alert_availability: "ready",
alerts: [{
id: "backend-alert-alarm-1",
condition_id: null,
source: {
task_type: "scheduled_task",
task_id: "scada-diagnosis-5m",
run_id: "trigger-1"
},
title: "泵站通信告警",
description: "由 SCADA 数据诊断任务明确推送。",
occurred_at: "2026-09-17T02:29:00Z",
severity: "warning"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
expect(snapshot.alertAvailability).toBe("ready");
expect(snapshot.alerts).toEqual([expect.objectContaining({
id: "backend-alert-alarm-1",
title: "泵站通信告警",
severity: "warning",
source: {
taskType: "scheduled_task",
taskId: "scada-diagnosis-5m",
runId: "trigger-1"
}
})]);
expect(snapshot.alerts[0]).not.toHaveProperty("conditionId");
});
});
@@ -0,0 +1,573 @@
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type {
ScheduledConditionRecord,
ScheduledWorkOrderItem,
WorkbenchAlert,
WorkbenchScenario
} from "../types";
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
// Keep legacy analysis values parseable while old servers are rolling forward;
// they are filtered before the operational workbench model is created.
const runtimeConditionSchema = z.object({
id: z.string().trim().min(1),
task_id: z.enum(["backend-analysis", "backend-schedule"]),
session_id: z.string().trim().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "completed", "warning", "error"]),
risk_level: z.enum(["normal", "attention", "critical"]),
scheduled_at: z.string().datetime({ offset: true }),
updated_at: z.string().datetime({ offset: true }),
detail: z.string().trim().min(1),
recommendation: z.string().trim().min(1).nullable(),
evidence: z.array(z.string().trim().min(1)),
model_name: z.enum(["TJWater analysis runtime", "TJWater scheduler"])
});
type RuntimeConditionPayload = z.infer<typeof runtimeConditionSchema>;
type OperationalRuntimeConditionPayload = RuntimeConditionPayload & {
task_id: "backend-schedule";
};
const runtimeAlertSchema = z.object({
id: z.string().trim().min(1),
condition_id: z.string().trim().min(1).nullable(),
source: z.object({
task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]),
task_id: z.string().trim().min(1),
run_id: z.string().trim().min(1)
}),
title: z.string().trim().min(1),
description: z.string().trim().min(1),
occurred_at: z.string().datetime({ offset: true }),
severity: z.enum(["warning", "critical"])
});
const runtimeScenarioSchema = z.object({
id: z.string().trim().min(1),
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
description: z.string().trim().min(1),
status: z.enum(["draft", "active", "review"]),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
run_status: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true })
});
const runtimeAnalysisResultSchema = z.object({
result_type: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
facts: z.array(z.object({
label: z.string().trim().min(1),
value: z.string().trim().min(1)
}))
});
const geoJsonFeatureCollectionSchema = z.custom<FeatureCollection<Geometry>>(
isGeoJsonFeatureCollection
);
const runtimeLatestAnalysisSchema = z.object({
run_id: z.string().trim().min(1),
name: z.string().trim().min(1),
run_type: z.string().trim().min(1),
run_type_label: z.string().trim().min(1),
status: z.string().trim().min(1),
status_label: z.string().trim().min(1),
created_by: z.string().trim().min(1),
created_at: z.string().datetime({ offset: true }),
started_at: z.string().datetime({ offset: true }),
results: z.array(runtimeAnalysisResultSchema),
map_payload: z.unknown().nullable()
});
const runtimeWorkOrderStageSchema = z.object({
id: z.string().trim().min(1),
sequence: z.number().int().positive(),
title: z.string().trim().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]),
owner: z.string().trim().min(1),
time_label: z.string().trim().min(1),
description: z.string().trim().min(1)
});
const runtimeWorkOrderSubtaskSchema = z.object({
id: z.string().trim().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]),
scheduled_at: z.string().datetime({ offset: true }),
location: z.string().trim().min(1),
assignee: z.string().trim().min(1),
duration_minutes: z.number().int().positive(),
reply_window_minutes: z.number().int().positive(),
stages: z.array(runtimeWorkOrderStageSchema).min(1),
reply_requirements: z.array(z.string().trim().min(1))
}).superRefine((subtask, context) => {
if (subtask.stages.some((stage, index) => stage.sequence !== index + 1)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段序号必须从 1 连续递增",
path: ["stages"]
});
}
if (new Set(subtask.stages.map((stage) => stage.id)).size !== subtask.stages.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "子任务阶段编号不能重复",
path: ["stages"]
});
}
if (subtask.stages.filter((stage) => stage.status === "current").length > 1) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "单个子任务最多只能有一个当前阶段",
path: ["stages"]
});
}
});
const runtimeWorkOrderSchema = z.object({
id: z.string().trim().min(1),
code: z.string().trim().min(1),
scheduled_at: z.string().datetime({ offset: true }),
title: z.string().trim().min(1),
summary: z.string().trim().min(1),
status: z.enum(["pending", "running", "awaiting_reply", "completed"]),
risk_level: z.enum(["normal", "attention", "critical"]),
updated_at: z.string().datetime({ offset: true }),
detail: z.string().trim().min(1),
recommendation: z.string().trim().min(1).nullable(),
duration_minutes: z.number().int().positive(),
source: z.string().trim().min(1),
dispatcher: z.string().trim().min(1),
priority: z.enum(["normal", "urgent"]),
subtasks: z.array(runtimeWorkOrderSubtaskSchema).min(1)
}).superRefine((workOrder, context) => {
if (new Set(workOrder.subtasks.map((subtask) => subtask.id)).size !== workOrder.subtasks.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "工单子任务编号不能重复",
path: ["subtasks"]
});
}
});
const runtimeSnapshotSchema = z.object({
availability: z.enum(["ready", "not_configured", "degraded"]),
source: z.enum(["none", "analysis_runs", "scheduled_tasks"]),
scenario_availability: z.enum(["ready", "not_configured", "degraded"]),
artifact_availability: z.enum(["ready", "not_configured", "degraded"]),
schedule_availability: z.enum(["ready", "not_configured", "degraded"]),
work_order_availability: z.enum(["ready", "not_configured", "degraded"]),
alert_availability: z.enum(["ready", "not_configured", "degraded"]),
generated_at: z.string().datetime({ offset: true }),
conditions: z.array(runtimeConditionSchema),
alerts: z.array(runtimeAlertSchema),
scenarios: z.array(runtimeScenarioSchema),
latest_analysis: runtimeLatestAnalysisSchema.nullable(),
work_orders: z.array(runtimeWorkOrderSchema)
});
type ProjectionAvailability = "ready" | "not_configured" | "degraded";
export type WorkbenchRuntimeSnapshot = {
availability: ProjectionAvailability;
source: "none" | "scheduled_tasks";
scenarioAvailability: ProjectionAvailability;
artifactAvailability: ProjectionAvailability;
scheduleAvailability: ProjectionAvailability;
workOrderAvailability: ProjectionAvailability;
alertAvailability: ProjectionAvailability;
generatedAt: string;
conditions: ScheduledConditionRecord[];
alerts: WorkbenchAlert[];
scenarios: WorkbenchScenario[];
latestAnalysisArtifact: AnalysisArtifact | null;
simulationOverlay: FeatureCollection<Geometry> | null;
workOrders: ScheduledWorkOrderItem[];
};
export type WorkbenchAnalysisProjection = {
runId: string;
artifact: AnalysisArtifact;
simulationOverlay: FeatureCollection<Geometry> | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchRuntimeSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchRuntimeSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/runtime`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "工作台运行态请求"));
}
const snapshot = runtimeSnapshotSchema.parse(payload);
const latestAnalysis = snapshot.latest_analysis
? projectWorkbenchAnalysis(snapshot.latest_analysis)
: null;
const operationalConditions = snapshot.conditions.filter(isOperationalRuntimeCondition);
return {
availability: snapshot.availability,
source: resolveOperationalRuntimeSource(snapshot, operationalConditions),
scenarioAvailability: snapshot.scenario_availability,
artifactAvailability: snapshot.artifact_availability,
scheduleAvailability: snapshot.schedule_availability,
workOrderAvailability: snapshot.work_order_availability,
alertAvailability: snapshot.alert_availability,
generatedAt: snapshot.generated_at,
conditions: operationalConditions.map((condition) => ({
id: condition.id,
kind: "condition",
taskId: condition.task_id,
dataSource: getConditionDataSource(condition.task_id),
sessionId: condition.session_id,
title: condition.title,
summary: condition.summary,
status: condition.status,
riskLevel: condition.risk_level,
scheduledAt: condition.scheduled_at,
updatedAt: Date.parse(condition.updated_at),
detail: condition.detail,
recommendation: condition.recommendation ?? undefined,
evidence: condition.evidence,
modelName: condition.model_name
})),
alerts: snapshot.alerts.map((alert) => ({
...(alert.condition_id ? { conditionId: alert.condition_id } : {}),
id: alert.id,
title: alert.title,
description: alert.description,
source: {
taskType: alert.source.task_type,
taskId: alert.source.task_id,
runId: alert.source.run_id
},
severity: alert.severity,
occurredAt: alert.occurred_at,
time: formatRuntimeAlertTime(alert.occurred_at)
})),
scenarios: snapshot.scenarios.map((scenario) => ({
id: scenario.id,
name: scenario.name,
description: scenario.description,
status: scenario.status,
runId: scenario.run_id,
runType: scenario.run_type,
runStatus: scenario.run_status,
createdAt: scenario.created_at
})),
latestAnalysisArtifact: latestAnalysis?.artifact ?? null,
simulationOverlay: latestAnalysis?.simulationOverlay ?? null,
workOrders: snapshot.work_orders.map((workOrder) => ({
id: workOrder.id,
kind: "work_order",
code: workOrder.code,
scheduledAt: workOrder.scheduled_at,
title: workOrder.title,
summary: workOrder.summary,
status: workOrder.status,
riskLevel: workOrder.risk_level,
updatedAt: Date.parse(workOrder.updated_at),
detail: workOrder.detail,
recommendation: workOrder.recommendation ?? undefined,
durationMinutes: workOrder.duration_minutes,
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
subtasks: workOrder.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({
id: stage.id,
sequence: stage.sequence,
title: stage.title,
status: stage.status,
owner: stage.owner,
timeLabel: stage.time_label,
description: stage.description
})),
replyRequirements: subtask.reply_requirements
}))
}))
};
}
export async function fetchWorkbenchAnalysisRun(
runId: string,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`,
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
}
const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致");
}
return projectWorkbenchAnalysis(analysis);
}
function projectWorkbenchAnalysis(
analysis: z.infer<typeof runtimeLatestAnalysisSchema>
): WorkbenchAnalysisProjection {
const parsedSimulationOverlay = analysis.map_payload
? geoJsonFeatureCollectionSchema.safeParse(analysis.map_payload)
: null;
const simulationOverlay = parsedSimulationOverlay?.success
? parsedSimulationOverlay.data
: null;
return {
runId: analysis.run_id,
artifact: createPersistedAnalysisArtifact(analysis, simulationOverlay),
simulationOverlay
};
}
function createPersistedAnalysisArtifact(
analysis: z.infer<typeof runtimeLatestAnalysisSchema>,
simulationOverlay: FeatureCollection<Geometry> | null
): AnalysisArtifact {
const resultTypes = [...new Set(analysis.results.map((result) => result.result_type))];
const completed = ["completed", "succeeded"].includes(analysis.status);
const lifecycle: ArtifactLifecycle = completed ? "reviewed" : "draft";
const artifactType = getArtifactType(analysis.run_type);
const factCount = analysis.results.reduce((total, result) => total + result.facts.length, 0);
const resultSummary = analysis.results.length > 0
? `后端记录状态为${analysis.status_label},已持久化 ${analysis.results.length} 条分析结果。`
: `后端记录状态为${analysis.status_label},当前未查询到独立结果记录。`;
return {
id: `persisted-analysis-${analysis.run_id}`,
origin: "backend",
type: artifactType,
lifecycle,
revision: 1,
title: analysis.name,
subtitle: `${analysis.run_type_label} · 项目后端分析运行`,
summary: resultSummary,
scope: `运行 ${analysis.run_id}`,
confidence: "后端未提供",
generatedAt: formatRuntimeDateTime(analysis.created_at),
analyticalTimeRange: {
start: analysis.started_at,
end: analysis.created_at
},
preferredView: simulationOverlay ? "map_split" : "focus",
mapRelation: simulationOverlay ? "required" : "none",
...(simulationOverlay ? { spatialOverlay: simulationOverlay } : {}),
blocks: [
{
id: `persisted-analysis-${analysis.run_id}-metrics`,
kind: "metric-grid",
title: "运行摘要",
span: 12,
metrics: [
{
label: "后端状态",
value: analysis.status_label,
detail: analysis.status,
tone: getStatusTone(analysis.status)
},
{
label: "结果记录",
value: String(analysis.results.length),
detail: "analysis.results",
tone: analysis.results.length > 0 ? "success" : "neutral"
},
{
label: "结果类型",
value: String(resultTypes.length),
detail: resultTypes.join("、") || "暂无",
tone: "info"
},
{
label: "成果事实",
value: String(factCount),
detail: factCount > 0 ? "后端已识别字段" : "暂无已识别字段",
tone: factCount > 0 ? "success" : "neutral"
},
{
label: "创建人",
value: analysis.created_by,
detail: analysis.run_type_label,
tone: "neutral"
}
]
},
{
id: `persisted-analysis-${analysis.run_id}-metadata`,
kind: "narrative",
title: "后端运行记录",
span: 12,
paragraphs: [
`运行 ID:${analysis.run_id}`,
`分析类型:${analysis.run_type_label}(${analysis.run_type})`,
`创建人:${analysis.created_by};状态:${analysis.status_label}。`
]
},
...(analysis.results.length > 0
? [{
id: `persisted-analysis-${analysis.run_id}-results`,
kind: "data-table" as const,
title: "持久化结果索引",
description: "来自项目 BizDB analysis.results 的结果记录",
span: 12 as const,
columns: [
{ key: "type", label: "结果类型" },
{ key: "createdAt", label: "写入时间" },
{ key: "facts", label: "后端成果事实" }
],
rows: analysis.results.map((result) => ({
type: result.result_type,
createdAt: formatRuntimeDateTime(result.created_at),
facts: result.facts.length > 0
? result.facts.map((fact) => `${fact.label}:${fact.value}`).join(";")
: "未提供已识别字段"
}))
}]
: [])
],
sources: [
"项目 BizDB · analysis.runs",
...(analysis.results.length > 0 ? ["项目 BizDB · analysis.results"] : [])
],
limitations: [
factCount > 0
? "当前投影展示后端持久化元数据、结果索引和已识别的成果字段;未透传任意结果载荷。"
: "当前投影只展示后端持久化元数据与结果索引。",
"后端记录没有提供综合置信度。"
],
actions: []
};
}
function getArtifactType(runType: string): ArtifactType {
if (["burst_detection", "burst_location", "dma_leak_identification"].includes(runType)) {
return "diagnosis";
}
if (runType === "sensor_placement") return "metric_analysis";
return "simulation";
}
function getStatusTone(status: string) {
if (["completed", "succeeded"].includes(status)) return "success" as const;
if (["failed", "error"].includes(status)) return "danger" as const;
if (status === "running") return "info" as const;
return "warning" as const;
}
function formatRuntimeAlertTime(value: string) {
const date = new Date(value);
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function getConditionDataSource(
_taskId: OperationalRuntimeConditionPayload["task_id"]
) {
return "scheduled_tasks" as const;
}
function isOperationalRuntimeCondition(
condition: RuntimeConditionPayload
): condition is OperationalRuntimeConditionPayload {
return condition.task_id === "backend-schedule";
}
function resolveOperationalRuntimeSource(
snapshot: z.infer<typeof runtimeSnapshotSchema>,
conditions: OperationalRuntimeConditionPayload[]
): WorkbenchRuntimeSnapshot["source"] {
if (snapshot.source === "scheduled_tasks") return "scheduled_tasks";
return conditions.length > 0 ? "scheduled_tasks" : "none";
}
function formatRuntimeDateTime(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 isGeoJsonFeatureCollection(value: unknown): value is FeatureCollection<Geometry> {
if (!isRecord(value) || value.type !== "FeatureCollection" || !Array.isArray(value.features)) {
return false;
}
return value.features.every((feature) =>
isRecord(feature)
&& feature.type === "Feature"
&& (feature.properties === null || isRecord(feature.properties))
&& (feature.geometry === null || isGeoJsonGeometry(feature.geometry))
);
}
function isGeoJsonGeometry(value: unknown): value is Geometry {
if (!isRecord(value) || typeof value.type !== "string") return false;
if (value.type === "GeometryCollection") {
return Array.isArray(value.geometries) && value.geometries.every(isGeoJsonGeometry);
}
const coordinates = value.coordinates;
if (value.type === "Point") return isPosition(coordinates);
if (value.type === "MultiPoint" || value.type === "LineString") {
return isPositionArray(coordinates);
}
if (value.type === "MultiLineString" || value.type === "Polygon") {
return Array.isArray(coordinates) && coordinates.every(isPositionArray);
}
if (value.type === "MultiPolygon") {
return Array.isArray(coordinates)
&& coordinates.every((polygon) =>
Array.isArray(polygon) && polygon.every(isPositionArray)
);
}
return false;
}
function isPosition(value: unknown): value is Position {
return Array.isArray(value)
&& value.length >= 2
&& value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
}
function isPositionArray(value: unknown): value is Position[] {
return Array.isArray(value) && value.every(isPosition);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}