feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user