feat: extend supply workbench demo and map navigation

This commit is contained in:
2026-09-23 13:19:30 +08:00
parent bebcf2a06c
commit 1752b9ef84
45 changed files with 2137 additions and 449 deletions
+1
View File
@@ -2,6 +2,7 @@ TJWATER_AUTH_MODE=required
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
TJWATER_MAPBOX_ACCESS_TOKEN= TJWATER_MAPBOX_ACCESS_TOKEN=
TJWATER_TIANDITU_TOKEN=
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater_next TJWATER_GEOSERVER_WORKSPACE=tjwater_next
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
+1
View File
@@ -5,6 +5,7 @@ dist-server
coverage coverage
playwright-report playwright-report
test-results test-results
/public/city-model
.env .env
.env.* .env.*
!.env.example !.env.example
+20 -19
View File
@@ -32,26 +32,27 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量: 浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` | | `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` | | `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID | | `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | | `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | | `TJWATER_TIANDITU_TOKEN` | 空 | 天地图矢量、影像及中文注记访问令牌 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | | `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | | `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | | `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | | `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | | `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | | `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | | `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 | | `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | | `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。 `.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。Mapbox 和天地图令牌会随底图请求发送到浏览器,应在服务商控制台限制允许的来源域名。
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。 浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
+2
View File
@@ -6,6 +6,7 @@ runtime_config=$(jq -cn \
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \ --arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \ --arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \ --arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
--arg tiandituToken "${TJWATER_TIANDITU_TOKEN:-}" \
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
@@ -23,6 +24,7 @@ runtime_config=$(jq -cn \
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer, TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId, TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken, TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_TIANDITU_TOKEN: $tiandituToken,
TJWATER_MAP_URL: $mapUrl, TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
+1
View File
@@ -48,6 +48,7 @@
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"motion": "^12.40.0", "motion": "^12.40.0",
"nanoid": "^5.1.14", "nanoid": "^5.1.14",
"pmtiles": "4.3.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"shiki": "^4.2.0", "shiki": "^4.2.0",
+16
View File
@@ -98,6 +98,9 @@ importers:
nanoid: nanoid:
specifier: ^5.1.14 specifier: ^5.1.14
version: 5.1.16 version: 5.1.16
pmtiles:
specifier: 4.3.0
version: 4.3.0
react: react:
specifier: ^19.2.7 specifier: ^19.2.7
version: 19.2.7 version: 19.2.7
@@ -2456,6 +2459,7 @@ packages:
eslint@9.39.5: eslint@9.39.5:
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -2531,6 +2535,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -3354,6 +3361,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
pmtiles@4.3.0:
resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==}
points-on-curve@0.2.0: points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -6271,6 +6281,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
fflate@0.8.3: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@@ -7373,6 +7385,10 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
pmtiles@4.3.0:
dependencies:
fflate: 0.8.3
points-on-curve@0.2.0: {} points-on-curve@0.2.0: {}
points-on-path@0.2.1: points-on-path@0.2.1:
+2
View File
@@ -2,6 +2,8 @@ globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required", TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "", TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_TIANDITU_TOKEN: "",
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000", TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000",
TJWATER_WORKBENCH_RUNTIME_MODE: "auto" TJWATER_WORKBENCH_RUNTIME_MODE: "auto"
}; };
+25 -3
View File
@@ -3,6 +3,25 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentEventTriggerContext = {
schema_version: "agent-trigger@1";
kind: "event";
trigger_id: string;
occurred_at: string;
source: "peripheral_sample";
subject_type: "work_order";
subject_id: string;
workflow: "water_quality_complaint";
payload: {
title: string;
summary: string;
location?: string | null;
priority: "low" | "normal" | "high" | "urgent";
scenario_code: "WQ-SECONDARY-SUPPLY-001";
origin: "synthetic";
};
};
export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = { export type AgentModelOption = {
@@ -29,6 +48,9 @@ export type AgentChatSession = {
updated_at?: number | string; updated_at?: number | string;
is_title_manually_edited?: boolean; is_title_manually_edited?: boolean;
parent_session_id?: string; parent_session_id?: string;
created?: boolean;
event_duplicate?: boolean;
trigger_id?: string;
}; };
export type AgentChatSessionSummary = { export type AgentChatSessionSummary = {
@@ -80,7 +102,7 @@ export type AgentSessionStreamEvent =
}; };
export type AgentApiClient = { export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>; createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>; listSessions: () => Promise<AgentChatSessionSummary[]>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>; loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: ( streamSession: (
@@ -154,11 +176,11 @@ export function createAgentApiClient(
}; };
return { return {
async createSession() { async createSession(options) {
return requestJson<AgentChatSession>("/session", { return requestJson<AgentChatSession>("/session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) body: JSON.stringify({ trigger_context: options?.triggerContext })
}); });
}, },
+1
View File
@@ -11,6 +11,7 @@ export type {
AgentApiClient, AgentApiClient,
AgentApiClientOptions, AgentApiClientOptions,
AgentChatSessionSummary, AgentChatSessionSummary,
AgentEventTriggerContext,
AgentLoadedChatSession, AgentLoadedChatSession,
AgentSessionStreamEvent AgentSessionStreamEvent
} from "./api/client"; } from "./api/client";
@@ -0,0 +1,69 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { getCombinedCameraAction, setThreeDimensionalNavigation } from "./camera-actions";
describe("combined camera mode action", () => {
it.each([
{ bearing: 0, pitch: 0, expected: "enter-3d" },
{ bearing: 0.4, pitch: 50, expected: "exit-3d" },
{ bearing: 0.6, pitch: 50, expected: "reset-north" },
{ bearing: -35, pitch: 45, expected: "reset-north" }
] as const)(
"resolves $expected for bearing=$bearing pitch=$pitch",
({ bearing, pitch, expected }) => {
expect(getCombinedCameraAction(bearing, pitch)).toBe(expected);
}
);
it("enables and disables only the 3D navigation gestures", () => {
const canvas = document.createElement("canvas");
const handlers = {
dragRotate: { enable: vi.fn(), disable: vi.fn() },
getCanvas: () => canvas,
touchPitch: { enable: vi.fn(), disable: vi.fn() },
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
};
const map = handlers as unknown as MapLibreMap;
setThreeDimensionalNavigation(map, true);
expect(handlers.dragRotate.enable).toHaveBeenCalledOnce();
expect(handlers.touchPitch.enable).toHaveBeenCalledOnce();
expect(handlers.touchZoomRotate.enableRotation).toHaveBeenCalledOnce();
setThreeDimensionalNavigation(map, false);
expect(handlers.dragRotate.disable).toHaveBeenCalledOnce();
expect(handlers.touchPitch.disable).toHaveBeenCalledOnce();
expect(handlers.touchZoomRotate.disableRotation).toHaveBeenCalledOnce();
});
it("overrides the browser context menu while 3D navigation is enabled", () => {
const canvas = document.createElement("canvas");
const onMapMouseDown = vi.fn();
canvas.addEventListener("mousedown", onMapMouseDown);
const map = {
dragRotate: { enable: vi.fn(), disable: vi.fn() },
getCanvas: () => canvas,
touchPitch: { enable: vi.fn(), disable: vi.fn() },
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
} as unknown as MapLibreMap;
setThreeDimensionalNavigation(map, true);
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 2 }));
expect(onMapMouseDown).toHaveBeenCalledOnce();
expect(
canvas.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
)
).toBe(false);
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ctrlKey: true }));
expect(onMapMouseDown).toHaveBeenCalledTimes(2);
setThreeDimensionalNavigation(map, false);
expect(
canvas.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
)
).toBe(true);
});
});
@@ -0,0 +1,42 @@
import type { Map as MapLibreMap } from "maplibre-gl";
export type CombinedCameraAction = "enter-3d" | "reset-north" | "exit-3d";
export const CAMERA_MODE_ANGLE_TOLERANCE = 0.5;
const browserContextMenuOverrides = new WeakMap<MapLibreMap, () => void>();
export function getCombinedCameraAction(bearing: number, pitch: number): CombinedCameraAction {
if (pitch <= CAMERA_MODE_ANGLE_TOLERANCE) return "enter-3d";
if (Math.abs(bearing) > CAMERA_MODE_ANGLE_TOLERANCE) return "reset-north";
return "exit-3d";
}
export function setThreeDimensionalNavigation(map: MapLibreMap, enabled: boolean) {
if (enabled) {
overrideBrowserContextMenu(map);
map.dragRotate.enable();
map.touchPitch.enable();
map.touchZoomRotate.enableRotation();
return;
}
browserContextMenuOverrides.get(map)?.();
map.dragRotate.disable();
map.touchPitch.disable();
map.touchZoomRotate.disableRotation();
}
function overrideBrowserContextMenu(map: MapLibreMap) {
if (browserContextMenuOverrides.has(map)) return;
const canvas = map.getCanvas();
const preventBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
canvas.addEventListener("contextmenu", preventBrowserContextMenu, true);
browserContextMenuOverrides.set(map, () => {
canvas.removeEventListener("contextmenu", preventBrowserContextMenu, true);
browserContextMenuOverrides.delete(map);
});
}
+134 -44
View File
@@ -1,13 +1,13 @@
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react"; import { Home, Minus, Navigation2, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react"; import { type ReactNode, type RefObject, useCallback, useEffect, useState } from "react";
import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import {
CAMERA_MODE_ANGLE_TOLERANCE,
getCombinedCameraAction,
setThreeDimensionalNavigation
} from "./camera-actions";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
@@ -19,10 +19,60 @@ type MapZoomProps = {
mapRef: RefObject<MapLibreMap | null>; mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean; mapReady: boolean;
onHome: () => void; onHome: () => void;
reducedMotion?: boolean;
className?: string; className?: string;
}; };
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) { export function MapZoom({
mapRef,
mapReady,
onHome,
reducedMotion = false,
className = ""
}: MapZoomProps) {
const [orientation, setOrientation] = useState({ bearing: 0, pitch: 0 });
const is3d = orientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE;
const cameraAction = getCombinedCameraAction(orientation.bearing, orientation.pitch);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
const syncOrientation = () => {
const nextOrientation = { bearing: map.getBearing(), pitch: map.getPitch() };
setOrientation(nextOrientation);
setThreeDimensionalNavigation(map, nextOrientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE);
};
syncOrientation();
map.on("pitch", syncOrientation);
map.on("rotate", syncOrientation);
return () => {
map.off("pitch", syncOrientation);
map.off("rotate", syncOrientation);
};
}, [mapRef, mapReady]);
const handleCameraMode = useCallback(() => {
const map = mapRef.current;
if (!map) return;
const action = getCombinedCameraAction(map.getBearing(), map.getPitch());
const duration = reducedMotion ? 0 : 350;
if (action === "enter-3d") {
setThreeDimensionalNavigation(map, true);
map.easeTo({
pitch: window.matchMedia("(max-width: 639px)").matches ? 45 : 50,
duration
});
return;
}
if (action === "reset-north") {
map.easeTo({ bearing: 0, duration });
return;
}
setThreeDimensionalNavigation(map, false);
map.easeTo({ pitch: 0, bearing: 0, duration });
}, [mapRef, reducedMotion]);
const handleZoomIn = useCallback(() => { const handleZoomIn = useCallback(() => {
mapRef.current?.zoomIn({ duration: 260 }); mapRef.current?.zoomIn({ duration: 260 });
}, [mapRef]); }, [mapRef]);
@@ -36,10 +86,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
<div <div
role="group" role="group"
aria-label="地图缩放" aria-label="地图缩放"
className={cn( className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
"pointer-events-auto flex w-12 flex-col gap-2",
className
)}
> >
<div <div
className={cn( className={cn(
@@ -48,34 +95,33 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
)} )}
> >
<ControlButton <ControlButton icon={Plus} label="放大" disabled={!mapReady} onClick={handleZoomIn} />
icon={Plus} <Divider />
label="放大" <ControlButton icon={Minus} label="缩小" disabled={!mapReady} onClick={handleZoomOut} />
disabled={!mapReady}
onClick={handleZoomIn}
/>
<Divider /> <Divider />
<ControlButton <ControlButton
icon={Minus} content={
label="缩小" <CompassModeIndicator
bearing={orientation.bearing}
pitch={orientation.pitch}
mode={is3d ? "3D" : "2D"}
/>
}
label={
cameraAction === "reset-north"
? "恢复北向"
: cameraAction === "exit-3d"
? "切换到二维"
: "切换到三维"
}
pressed={is3d}
disabled={!mapReady} disabled={!mapReady}
onClick={handleZoomOut} onClick={handleCameraMode}
/> />
</div> </div>
<div <div className={cn("p-1", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
className={cn( <ControlButton icon={Home} label="缩放到全局" disabled={!mapReady} onClick={onHome} />
"p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<ControlButton
icon={Home}
label="缩放到全局"
disabled={!mapReady}
onClick={onHome}
/>
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -83,19 +129,29 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
} }
type ControlButtonProps = { type ControlButtonProps = {
icon: LucideIcon; icon?: LucideIcon;
content?: ReactNode;
pressed?: boolean;
label: string; label: string;
disabled: boolean; disabled: boolean;
onClick: () => void; onClick: () => void;
}; };
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) { function ControlButton({
icon: Icon,
content,
pressed,
label,
disabled,
onClick
}: ControlButtonProps) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label={label} aria-label={label}
aria-pressed={pressed}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
@@ -103,11 +159,11 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700", "bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
disabled && pressed && "bg-blue-50 text-blue-700",
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400" disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
)} )}
> >
<Icon size={18} strokeWidth={2.2} aria-hidden="true" /> {Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
@@ -121,11 +177,45 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
); );
} }
function Divider() { function CompassModeIndicator({
bearing,
pitch,
mode
}: {
bearing: number;
pitch: number;
mode: "2D" | "3D";
}) {
return ( return (
<div <span
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70" className="relative grid h-7 w-7 place-items-center"
style={{ perspective: "70px" }}
aria-hidden="true" aria-hidden="true"
/> data-map-compass="true"
data-bearing={bearing.toFixed(1)}
data-pitch={pitch.toFixed(1)}
data-map-mode={mode.toLowerCase()}
>
<Navigation2
size={17}
strokeWidth={2.2}
className="transition-transform duration-150 ease-out motion-reduce:transition-none"
style={{ transform: `rotateX(${Math.min(pitch, 60) * 0.65}deg) rotateZ(${-bearing}deg)` }}
/>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 min-w-4 rounded-[4px] px-0.5 text-center text-[8px] font-bold leading-3 tracking-[-0.02em] shadow-xs ring-1",
mode === "3D"
? "bg-blue-600 text-white ring-blue-700/30"
: "bg-white text-slate-600 ring-slate-300"
)}
>
{mode}
</span>
</span>
); );
} }
function Divider() {
return <div className="my-0.5 h-px w-3.5 self-center bg-slate-300/70" aria-hidden="true" />;
}
@@ -80,4 +80,33 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.getByText(/只保存在当前页面/)).toBeVisible(); expect(screen.getByText(/只保存在当前页面/)).toBeVisible();
expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument(); expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument();
}); });
it("renders an event-triggered water-quality task as a workflow before opening chat", () => {
const onContinueConversation = vi.fn();
render(
<ConditionDetailPanel
condition={{
...baseCondition,
id: "peripheral-water-quality-order-1",
taskId: "water-quality-complaint",
dataSource: "mock",
sessionId: "agent-session-water-quality",
title: "水质投诉调查",
status: "running",
riskLevel: "attention",
scheduledAt: new Date().toISOString(),
durationMinutes: 15,
evidence: ["来源工单:order-1", "投诉位置:演示小区"]
}}
dataSource="mock"
onContinueConversation={onContinueConversation}
/>
);
expect(screen.getByText("演示执行流程 · 水质投诉调查流程")).toBeVisible();
expect(screen.getAllByText("校验上游水质监测").at(-1)).toBeVisible();
expect(screen.getAllByText("核查二次供水").at(-1)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "与 Agent 协同" }));
expect(onContinueConversation).toHaveBeenCalledOnce();
});
}); });
@@ -226,7 +226,10 @@ function ConditionRecordDetailPanel({
backendCondition ? ( backendCondition ? (
<PersistedRunningConditionBody condition={condition} /> <PersistedRunningConditionBody condition={condition} />
) : ( ) : (
<RunningConditionBody condition={condition} /> <RunningConditionBody
condition={condition}
onContinueConversation={onContinueConversation}
/>
) )
) : backendCondition ? ( ) : backendCondition ? (
<PersistedConditionBody <PersistedConditionBody
@@ -416,8 +419,15 @@ function WorkOrderDetailPanel({
); );
} }
function RunningConditionBody({ condition }: { condition: ScheduledConditionRecord }) { function RunningConditionBody({
condition,
onContinueConversation
}: {
condition: ScheduledConditionRecord;
onContinueConversation: () => void;
}) {
const runningEvidence = (condition.evidence ?? []).slice(0, 2); const runningEvidence = (condition.evidence ?? []).slice(0, 2);
const conversationAvailable = condition.taskId === "water-quality-complaint";
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -435,11 +445,12 @@ function RunningConditionBody({ condition }: { condition: ScheduledConditionReco
<RunningEvidencePreview items={runningEvidence} /> <RunningEvidencePreview items={runningEvidence} />
<DecisionActionStrip <DecisionActionStrip
icon={Bot} icon={Bot}
title="阶段判断" title={conversationAvailable ? "Agent 协同" : "阶段判断"}
value={condition.recommendation ?? "等待本轮工况完成后再确认是否生成处置建议。"} value={condition.recommendation ?? "等待本轮工况完成后再确认是否生成处置建议。"}
actionLabel="等待完成" actionLabel={conversationAvailable ? "与 Agent 协同" : "等待完成"}
actionDisabled actionDisabled={!conversationAvailable}
actionTitle="任务执行中,完成后可继续对话" actionTitle={conversationAvailable ? "打开这条任务关联的 Agent 会话" : "任务执行中,完成后可继续对话"}
onAction={conversationAvailable ? onContinueConversation : undefined}
/> />
</> </>
); );
@@ -142,6 +142,37 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
detail: "输出启停优化、压力设定和继续观察条件。" detail: "输出启停优化、压力设定和继续观察条件。"
} }
] ]
},
"water-quality-complaint": {
title: "水质投诉调查流程",
reportLabel: "形成调查结论",
steps: [
{
id: "accept-work-order",
title: "接收投诉工单",
detail: "读取投诉类型、发生位置、时间和问题描述,建立事件与工单的可追溯关联。"
},
{
id: "check-upstream-quality",
title: "校验上游水质监测",
detail: "核查上游监测点的数据完整性、时效性及关键水质指标是否处于正常范围。"
},
{
id: "locate-supply-segment",
title: "研判供水链路",
detail: "若上游存在异常,转入水厂或管网排查;若上游正常,继续核查小区二次供水环节。"
},
{
id: "check-secondary-supply",
title: "核查二次供水",
detail: "检查水箱、泵房、消毒记录、清洗记录及同区域投诉,判断是否为局部设施问题。"
},
{
id: "prepare-disposition",
title: "汇总结论与处置",
detail: "整理证据、责任环节和建议动作,明确需要人工确认、采样或现场处置的事项。"
}
]
} }
}; };
@@ -21,6 +21,7 @@ import {
type AgentApprovalMode, type AgentApprovalMode,
type AgentChatMessage, type AgentChatMessage,
type AgentConnectionStatus, type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption, type AgentModelOption,
type AgentPermissionReply, type AgentPermissionReply,
type AgentPermissionRequest, type AgentPermissionRequest,
@@ -694,6 +695,70 @@ export function useWorkbenchAgent({
[approvalMode, chat, selectedModel, streaming] [approvalMode, chat, selectedModel, streaming]
); );
const triggerEvent = useCallback(
async (trigger: AgentEventTriggerContext, prompt: string) => {
if (streaming) {
throw new Error("Agent 正在处理上一条指令,请稍后重试事件触发。");
}
stopSessionStreamSubscription();
const reserved = await clientRef.current.createSession({ triggerContext: trigger });
const nextSessionId = reserved.session_id;
const loadedSession = await clientRef.current.loadSession(nextSessionId);
if (!loadedSession) {
throw new Error("Agent 事件会话创建后无法读取。");
}
applySessionId(nextSessionId);
setSessionTitle(loadedSession.title || "水质投诉事件调查");
setPermissionOverrides({});
setQuestionOverrides({});
const nextMessages = toAgentUiMessages(loadedSession.messages);
chat.setMessages(nextMessages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setConnectionStatus("online");
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
setStatusLabel("Agent 事件调查进行中");
startSessionStreamSubscription(nextSessionId);
return { sessionId: nextSessionId, reused: true };
}
if (nextMessages.length > 0) {
setStatusLabel("Agent 事件调查会话已打开");
await refreshSessionHistory();
return { sessionId: nextSessionId, reused: true };
}
setStatusLabel("Agent 正在调查水质投诉事件");
const eventRun = chat.sendMessage(
{ text: prompt },
{
body: {
session_id: nextSessionId,
model: selectedModel || undefined,
approval_mode: approvalMode,
trigger_context: trigger
}
}
);
void eventRun
.then(() => refreshSessionHistory())
.catch(() => undefined);
return { sessionId: nextSessionId, reused: reserved.event_duplicate === true };
},
[
approvalMode,
applySessionId,
chat,
refreshSessionHistory,
selectedModel,
startSessionStreamSubscription,
stopSessionStreamSubscription,
streaming
]
);
const stopPrompt = useCallback(() => { const stopPrompt = useCallback(() => {
chat.stop(); chat.stop();
stopSessionStreamSubscription(); stopSessionStreamSubscription();
@@ -845,6 +910,7 @@ export function useWorkbenchAgent({
replyPermission, replyPermission,
replyQuestion, replyQuestion,
resolveRenderRef, resolveRenderRef,
triggerEvent,
submitPrompt submitPrompt
}; };
} }
@@ -50,6 +50,7 @@ import {
scadaInteractionLayers scadaInteractionLayers
} from "../map/scada"; } from "../map/scada";
import { registerSupplyAssetImages } from "../map/supply-icons"; import { registerSupplyAssetImages } from "../map/supply-icons";
import { installCityScene } from "../map/city-scene-control";
import { useMapInteractions } from "./use-map-interactions"; import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility"; import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
@@ -79,7 +80,10 @@ type UseWorkbenchMapResult = {
sourceStatuses: WorkbenchSourceStatus[]; sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[]; availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>; sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>; getScadaFeatures: (
deviceIds?: string[],
signal?: AbortSignal
) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null; scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean; refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void; fitNetworkBounds: () => void;
@@ -96,6 +100,7 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = { const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图", "mapbox-base": "Mapbox 底图",
"tianditu-base": "天地图底图",
"geoserver-mvt": "GeoServer MVT", "geoserver-mvt": "GeoServer MVT",
"geoserver-catalog": "GeoServer 图层目录", "geoserver-catalog": "GeoServer 图层目录",
"scada-api": "SCADA API", "scada-api": "SCADA API",
@@ -106,6 +111,10 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
const SOURCE_GROUP_BY_ID: Record<string, string> = { const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base", "mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base", "mapbox-satellite": "mapbox-base",
"tianditu-vector": "tianditu-base",
"tianditu-vector-annotation": "tianditu-base",
"tianditu-image": "tianditu-base",
"tianditu-image-annotation": "tianditu-base",
...Object.fromEntries( ...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"]) GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
), ),
@@ -197,9 +206,11 @@ export function useWorkbenchMap({
? current ? current
: availability.availableSourceIds : availability.availableSourceIds
); );
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts) setSourceFeatureCounts((current) =>
? current areFeatureCountsEqual(current, availability.featureCounts)
: availability.featureCounts); ? current
: availability.featureCounts
);
if (availability.failedSourceIds.length > 0) { if (availability.failedSourceIds.length > 0) {
updateSourceStatus( updateSourceStatus(
@@ -251,12 +262,13 @@ export function useWorkbenchMap({
setMapReady(false); setMapReady(false);
setMapError(null); setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined; const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const tiandituToken = env.TJWATER_TIANDITU_TOKEN || undefined;
const scadaAbortController = new AbortController(); const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined; let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。"); updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style: createBaseStyle(mapboxToken), style: createBaseStyle(mapboxToken, tiandituToken),
pitch: 0, pitch: 0,
bearing: 0, bearing: 0,
maxZoom: MAP_MAX_ZOOM, maxZoom: MAP_MAX_ZOOM,
@@ -265,9 +277,11 @@ export function useWorkbenchMap({
}); });
window.__waterNetworkMap = map; window.__waterNetworkMap = map;
let removeCityScene: (() => void) | undefined;
map.on("load", async () => { map.on("load", async () => {
map.resize(); map.resize();
removeCityScene = installCityScene(map);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData( (map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current scadaCollectionRef.current
); );
@@ -279,11 +293,7 @@ export function useWorkbenchMap({
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations); map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis); map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData( syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
map,
simulationOverlayRef.current,
allowDemoSimulationRef.current
);
await registerSupplyAssetImages(map); await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map); const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers = const activeScadaBusinessLayers =
@@ -340,10 +350,7 @@ export function useWorkbenchMap({
}); });
const refreshScada = async () => { const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot( const snapshotPromise = fetchScadaSnapshot(getAccessToken, scadaAbortController.signal);
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = snapshotPromise scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection) .then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current); .catch(() => scadaCollectionRef.current);
@@ -428,6 +435,7 @@ export function useWorkbenchMap({
if (scadaRefreshTimeoutId !== undefined) { if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId); window.clearTimeout(scadaRefreshTimeoutId);
} }
removeCityScene?.();
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
delete window.__waterNetworkMap; delete window.__waterNetworkMap;
@@ -500,8 +508,10 @@ function areFeatureCountsEqual(
) { ) {
const currentEntries = Object.entries(current); const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next); const nextEntries = Object.entries(next);
return currentEntries.length === nextEntries.length return (
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count); currentEntries.length === nextEntries.length &&
nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count)
);
} }
function networkViewKey(view: NetworkViewParams) { function networkViewKey(view: NetworkViewParams) {
@@ -549,8 +559,9 @@ function syncDataDependentGeoServerLayers(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIdSet.has(sourceId)) return; if (availableSourceIdSet.has(sourceId)) return;
map.getStyle().layers map
.filter((layer) => "source" in layer && layer.source === sourceId) .getStyle()
.layers.filter((layer) => "source" in layer && layer.source === sourceId)
.reverse() .reverse()
.forEach((layer) => map.removeLayer(layer.id)); .forEach((layer) => map.removeLayer(layer.id));
if (map.getSource(sourceId)) map.removeSource(sourceId); if (map.getSource(sourceId)) map.removeSource(sourceId);
@@ -711,11 +722,15 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
return "mapbox-base"; return "mapbox-base";
} }
if (message.includes("tianditu.gov.cn") || message.includes("tianditu")) {
return "tianditu-base";
}
return null; return null;
} }
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) { function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") { if (["mapbox-base", "tianditu-base"].includes(sourceGroupId)) {
return status === "online" return status === "online"
? "底图瓦片已恢复正常。" ? "底图瓦片已恢复正常。"
: "底图瓦片请求中断,业务图层仍可继续使用。"; : "底图瓦片请求中断,业务图层仍可继续使用。";
@@ -13,6 +13,8 @@ describe("workbench basemap surface tone", () => {
it.each([ it.each([
["mapbox-light", "light"], ["mapbox-light", "light"],
["mapbox-satellite", "satellite"], ["mapbox-satellite", "satellite"],
["tianditu-vector", "light"],
["tianditu-image", "satellite"],
["none", "none"], ["none", "none"],
["unknown", "none"] ["unknown", "none"]
])("maps %s to %s", (baseLayerId, expectedTone) => { ])("maps %s to %s", (baseLayerId, expectedTone) => {
@@ -37,11 +37,8 @@ export const WORKBENCH_LAYOUT = {
} as const; } as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) { export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } = const { edgeInset, gap, dockHeight, timelineSummaryHeight } = WORKBENCH_LAYOUT.mobileOverlay;
WORKBENCH_LAYOUT.mobileOverlay; const dockBottom = timelineVisible ? edgeInset + timelineSummaryHeight + gap : edgeInset;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return { return {
timelineBottom: edgeInset, timelineBottom: edgeInset,
@@ -118,11 +115,11 @@ export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchView
} }
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone { export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
if (activeBaseLayerId === "mapbox-light") { if (["mapbox-light", "tianditu-vector"].includes(activeBaseLayerId)) {
return "light"; return "light";
} }
if (activeBaseLayerId === "mapbox-satellite") { if (["mapbox-satellite", "tianditu-image"].includes(activeBaseLayerId)) {
return "satellite"; return "satellite";
} }
+211 -134
View File
@@ -35,6 +35,12 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel"; import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel"; import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
waterQualityTriggerPrompt
} from "./peripheral-sample/work-order-agent-trigger";
import { MapDevPanel } from "./components/map-dev-panel"; import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
@@ -56,9 +62,7 @@ import {
type ToolbarToolId type ToolbarToolId
} from "./components/toolbar-panel"; } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar"; import { WorkbenchTopBar } from "./components/workbench-top-bar";
import { import { WorkbenchMainFrame } from "./workspace/workbench-main-frame";
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace"; import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import { import {
createInitialWorkspaceContext, createInitialWorkspaceContext,
@@ -71,10 +75,7 @@ import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation"; import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout"; import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
formatHeaderDataTime,
useWorkbenchRuntimeData
} from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { import {
useWorkbenchMeasurement, useWorkbenchMeasurement,
@@ -121,6 +122,8 @@ export function MapWorkbenchPage({
getAccessToken?: AccessTokenProvider; getAccessToken?: AccessTokenProvider;
}) { }) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken); const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
@@ -138,7 +141,7 @@ export function MapWorkbenchPage({
requestId: number; requestId: number;
} | null>(null); } | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState( const [activeBaseLayerId, setActiveBaseLayerId] = useState(
hasMapboxToken ? "mapbox-light" : "none" hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none"
); );
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId); const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance"); const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
@@ -149,7 +152,7 @@ export function MapWorkbenchPage({
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]); const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const { const {
headerDataTime, headerDataTime,
scheduledConditions, scheduledConditions: runtimeScheduledConditions,
scheduledConditionsLoading, scheduledConditionsLoading,
workbenchAlerts, workbenchAlerts,
runtimeDataSource, runtimeDataSource,
@@ -161,13 +164,22 @@ export function MapWorkbenchPage({
operationalDate, operationalDate,
operationalCursorTime operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken); } = useWorkbenchRuntimeData(getAccessToken);
const [eventConditions, setEventConditions] = useState<ScheduledConditionRecord[]>([]);
const scheduledConditions = useMemo(
() => [...eventConditions, ...runtimeScheduledConditions],
[eventConditions, runtimeScheduledConditions]
);
const baseLayerOptions = useMemo( const baseLayerOptions = useMemo(
() => () =>
BASE_LAYER_OPTIONS.map((option) => ({ BASE_LAYER_OPTIONS.map((option) => ({
...option, ...option,
disabled: option.id !== "none" && !hasMapboxToken disabled: option.id.startsWith("mapbox-")
? !hasMapboxToken
: option.id.startsWith("tianditu-")
? !hasTiandituToken
: false
})), })),
[hasMapboxToken] [hasMapboxToken, hasTiandituToken]
); );
const handleSelectFeature = useCallback((feature: DetailFeature) => { const handleSelectFeature = useCallback((feature: DetailFeature) => {
@@ -215,19 +227,18 @@ export function MapWorkbenchPage({
expandAgentPanel, expandAgentPanel,
onClearActiveTool: clearActiveTool onClearActiveTool: clearActiveTool
}); });
const currentAgentWidth = leftPanelOpen const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth); const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth); const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null; const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null; const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length); const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation = artifactWorkspace.activeArtifact === null const allowDemoSimulation =
|| artifactWorkspace.activeArtifact.origin === "demo"; artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo";
const latestAnalysisAvailable = Boolean( const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some((block) => latestAnalysisArtifact?.blocks.some(
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow" (block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
) )
); );
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact"); const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
@@ -278,15 +289,62 @@ export function MapWorkbenchPage({
useEffect(() => { useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView); setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView setLayerVisibility((current) =>
? current current.simulation === activeArtifactHasSpatialView
: { ...current, simulation: activeArtifactHasSpatialView }); ? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]); }, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
openAgentPanelForViewport(); openAgentPanelForViewport();
} }
async function handlePeripheralWorkOrderAgentTrigger(workOrder: PeripheralWorkOrder) {
const trigger = toWaterQualityAgentTrigger(workOrder);
if (!trigger) {
throw new Error("工单不符合水质投诉事件触发条件。");
}
showMapNotice({
id: "water-quality-agent-trigger",
tone: "loading",
title: "水质投诉事件已接收",
message: "正在建立结构化工况任务。",
duration: Infinity
});
try {
const result = await agent.triggerEvent(trigger, waterQualityTriggerPrompt(workOrder));
const condition = toWaterQualityConditionTask(workOrder, result.sessionId);
setEventConditions((current) => [
condition,
...current.filter((item) => item.id !== condition.id)
]);
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setSelectedConditionId(condition.id);
setConditionFocusRequest((current) => ({
conditionId: condition.id,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "water-quality-agent-trigger",
tone: "success",
title: result.reused ? "已恢复水质工况任务" : "已建立水质工况任务",
message: `关联模拟工单 ${workOrder.work_order_id.slice(0, 8)},可在任务中查看处理流程。`
});
} catch (error) {
showMapNotice({
id: "water-quality-agent-trigger",
tone: "error",
title: "工单已创建,Agent 待触发",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
}
}
function openConditionWorkspaceForViewport() { function openConditionWorkspaceForViewport() {
openConditionFeedForViewport(); openConditionFeedForViewport();
} }
@@ -325,10 +383,10 @@ export function MapWorkbenchPage({
networkView: projectPresentation.networkView, networkView: projectPresentation.networkView,
getAccessToken getAccessToken
}); });
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE) const displayedDataTime =
&& scadaObservedAt !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE) && scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false) ? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime; : headerDataTime;
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef, mapRef,
mapReady, mapReady,
@@ -378,12 +436,12 @@ export function MapWorkbenchPage({
}); });
const taskTickerAvailable = runningTickerConditions.length > 0; const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = const shouldShowTaskTicker =
!artifactActive !artifactActive &&
&& mobileSheet === null mobileSheet === null &&
&& !conditionFeedExpanded !conditionFeedExpanded &&
&& detailFeature === null detailFeature === null &&
&& taskTickerVisible taskTickerVisible &&
&& taskTickerAvailable; taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion const taskTickerEnterState = prefersReducedMotion
? { ? {
opacity: 1, opacity: 1,
@@ -436,41 +494,44 @@ export function MapWorkbenchPage({
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current)); setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []); }, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => { const handleSelectOperationalEvent = useCallback(
dispatchWorkspaceContext({ (event: OperationalEvent) => {
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
dispatchWorkspaceContext({ dispatchWorkspaceContext({
type: "select-spatial-feature", type: "select-operational-event",
featureId: mapTarget.featureId, eventId: event.id,
sourceId: mapTarget.sourceId cursorTime: event.cursorTime
}); });
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) { const mapTarget = event.mapTargets[0];
setSelectedConditionId(event.conditionId); if (mapTarget) {
setConditionFocusRequest((current) => ({ dispatchWorkspaceContext({
conditionId: event.conditionId!, type: "select-spatial-feature",
requestId: (current?.requestId ?? 0) + 1 featureId: mapTarget.featureId,
})); sourceId: mapTarget.sourceId
openConditionFeedForViewport(); });
handleConditionExpandedChange(true); void mapController.locateAndHighlight(mapTarget).then(() => {
} if (mapController.getSnapshot().errorCode) {
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]); showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
},
[handleConditionExpandedChange, mapController, openConditionFeedForViewport]
);
const handleClearOperationalSelection = useCallback(() => { const handleClearOperationalSelection = useCallback(() => {
dispatchWorkspaceContext({ type: "clear-operational-event" }); dispatchWorkspaceContext({ type: "clear-operational-event" });
@@ -902,16 +963,18 @@ export function MapWorkbenchPage({
onShowShortcuts: handleShowShortcuts, onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig onExportConfig: handleExportConfig
}; };
const selectedOperationalEvent = operationalEvents.find( const selectedOperationalEvent =
(event) => event.id === workspaceContext.operational.selectedEventId operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
) ?? null; null;
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => { const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
const items: AgentWorkspaceContextItem[] = [{ const items: AgentWorkspaceContextItem[] = [
id: "operational-time", {
label: "运行时点", id: "operational-time",
value: formatContextClock(workspaceContext.operational.cursorTime), label: "运行时点",
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral" value: formatContextClock(workspaceContext.operational.cursorTime),
}]; tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}
];
if (workspaceContext.spatial.featureId) { if (workspaceContext.spatial.featureId) {
items.push({ items.push({
@@ -980,9 +1043,11 @@ export function MapWorkbenchPage({
> >
<WorkbenchTopBar <WorkbenchTopBar
dataTime={displayedDataTime} dataTime={displayedDataTime}
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label modelName={
|| agent.selectedModel agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label ||
|| "Agent 后端连接中"} agent.selectedModel ||
"Agent 后端连接中"
}
activeAnalysisArtifact={artifactWorkspace.activeArtifact} activeAnalysisArtifact={artifactWorkspace.activeArtifact}
alerts={workbenchAlerts} alerts={workbenchAlerts}
alertDataSource={alertDataSource} alertDataSource={alertDataSource}
@@ -999,8 +1064,14 @@ export function MapWorkbenchPage({
onToggleConditionFeed={toggleConditionWorkspaceForViewport} onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility} onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)} onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }} onToggleAnalysisResultsPanel={() => {
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }} setPeripheralSamplePanelOpen(false);
setAnalysisResultsPanelOpen((current) => !current);
}}
onTogglePeripheralSamplePanel={() => {
setAnalysisResultsPanelOpen(false);
setPeripheralSamplePanelOpen((current) => !current);
}}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)} onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent} onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
latestAnalysisAvailable={latestAnalysisAvailable} latestAnalysisAvailable={latestAnalysisAvailable}
@@ -1029,6 +1100,7 @@ export function MapWorkbenchPage({
open={peripheralSamplePanelOpen} open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)} onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken} getAccessToken={getAccessToken}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
/> />
<WorkbenchMainFrame <WorkbenchMainFrame
@@ -1043,56 +1115,58 @@ export function MapWorkbenchPage({
onDestroyArtifact={artifactWorkspace.destroyArtifact} onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode} timelineMode={timelineMode}
timelineBottom={timelineBottom} timelineBottom={timelineBottom}
timelineContent={( timelineContent={
timelineVisible ? <> timelineVisible ? (
<div className="hidden lg:block"> <>
<OperationalTimeline <div className="hidden lg:block">
date={workspaceContext.operational.date} <OperationalTimeline
cursorTime={workspaceContext.operational.cursorTime} date={workspaceContext.operational.date}
events={operationalEvents} cursorTime={workspaceContext.operational.cursorTime}
mode={timelineMode} events={operationalEvents}
selectedEventId={workspaceContext.operational.selectedEventId} mode={timelineMode}
onModeChange={setTimelineMode} selectedEventId={workspaceContext.operational.selectedEventId}
onSelectEvent={handleSelectOperationalEvent} onModeChange={setTimelineMode}
onClearSelection={handleClearOperationalSelection} onSelectEvent={handleSelectOperationalEvent}
onReturnNow={handleReturnOperationalNow} onClearSelection={handleClearOperationalSelection}
/> onReturnNow={handleReturnOperationalNow}
</div> />
<div </div>
role="region" <div
aria-label="移动端运行时间轴摘要" role="region"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden" aria-label="移动端运行时间轴摘要"
> className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
> >
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" /> <button
<span className="shrink-0 font-semibold">运行时间轴</span> type="button"
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]"> aria-label="打开完整运行时间轴"
<span className="text-slate-500">计划 {operationalSummary.planned}</span> onClick={openTimelineForViewport}
<span className="text-slate-500">执行 {operationalSummary.active}</span> className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
<span className="text-amber-700">关注 {operationalSummary.attention}</span> >
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
</span> <span className="shrink-0 font-semibold">运行时间轴</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline"> <span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
查看全天 <span className="text-slate-500">计划 {operationalSummary.planned}</span>
</span> <span className="text-slate-500">执行 {operationalSummary.active}</span>
</button> <span className="text-amber-700">关注 {operationalSummary.attention}</span>
</div> <span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</> : null </span>
)} <span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
mapContent={( 查看全天
</span>
</button>
</div>
</>
) : null
}
mapContent={
<div <div
ref={mapContainerRef} ref={mapContainerRef}
className="map-grid absolute inset-0" className="map-grid absolute inset-0"
style={{ position: "absolute", inset: 0 }} style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图" aria-label="供水管网地图"
/> />
)} }
mapOverlay={( mapOverlay={
<div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-0">
{!devPanelOpen ? ( {!devPanelOpen ? (
<> <>
@@ -1106,22 +1180,25 @@ export function MapWorkbenchPage({
) : null} ) : null}
<div <div
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]" className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{ style={
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`, {
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px` "--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
} as CSSProperties} "--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties
}
> >
<div className="flex items-end gap-2 lg:pr-2"> <div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} /> <MapZoom
mapRef={mapRef}
mapReady={mapReady}
onHome={fitNetworkBounds}
reducedMotion={prefersReducedMotion ?? false}
/>
</div> </div>
<MapScaleLine <MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
className="hidden lg:inline-flex"
mapRef={mapRef}
mapReady={mapReady}
/>
</div> </div>
</div> </div>
)} }
/> />
<WorkbenchAgentPanels <WorkbenchAgentPanels
@@ -1307,11 +1384,11 @@ export function MapWorkbenchPage({
{!mapReady && !mapError ? ( {!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" /> <MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
) : null} ) : null}
{!hasMapboxToken ? ( {!hasOnlineBasemap ? (
<MapSourceStatusNotice <MapSourceStatusNotice
sourceName="Mapbox 底图" sourceName="在线底图"
status="degraded" status="degraded"
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。" message="未检测到 Mapbox 或天地图令牌,当前已切换为无底图模式,仅展示业务图层。"
/> />
) : null} ) : null}
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => ( {sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
+15 -3
View File
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera"; import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources"; import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) { function createMap(width = 1280, height = 720, bearing = 0, pitch = 0) {
const fitBounds = vi.fn(); const fitBounds = vi.fn();
const map = { const map = {
fitBounds, fitBounds,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height }) getBearing: () => bearing,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height }),
getPitch: () => pitch
} as unknown as MapLibreMap; } as unknown as MapLibreMap;
return { fitBounds, map }; return { fitBounds, map };
@@ -87,10 +89,20 @@ describe("fitNetworkBounds", () => {
expect(bounds[1][0]).toBeCloseTo(121.772482, 6); expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6); expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({ expect(options).toMatchObject({
bearing: 0,
duration: 600, duration: 600,
maxZoom: 13, maxZoom: 13,
padding: { top: 96, right: 48, bottom: 48, left: 48 } padding: { top: 96, right: 48, bottom: 48, left: 48 },
pitch: 0
}); });
expect(options).not.toHaveProperty("zoom"); expect(options).not.toHaveProperty("zoom");
}); });
it("preserves the current 3D orientation when returning to the global extent", () => {
const { fitBounds, map } = createMap(1280, 720, 42, 50);
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds.mock.calls[0]?.[1]).toMatchObject({ bearing: 42, pitch: 50 });
});
}); });
+2
View File
@@ -53,6 +53,8 @@ export function fitNetworkBounds(
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), { map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
padding: getResponsivePadding(map, padding), padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM, maxZoom: GLOBAL_VIEW_MAX_ZOOM,
bearing: map.getBearing(),
pitch: map.getPitch(),
duration: 600 duration: 600
}); });
} }
@@ -0,0 +1,87 @@
import maplibregl, { type Map as MapLibreMap } from "maplibre-gl";
import {
CITY_LAYERS,
CITY_ROOT,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
import { Protocol } from "pmtiles";
const cityTileProtocol = new Protocol();
maplibregl.addProtocol("pmtiles", cityTileProtocol.tile);
class CitySceneControl {
private map?: MapLibreMap;
private initialized = false;
private attribution?: maplibregl.AttributionControl;
attach(map: MapLibreMap) {
this.map = map;
map.on("pitchend", this.syncVisibility);
this.syncVisibility();
}
private syncVisibility = () => {
const map = this.map;
if (!map) return;
const enabled = map.getPitch() > 0;
if (enabled && !this.initialized) this.addLayers();
if (!this.initialized) return;
CITY_LAYERS.forEach(({ id }) =>
map.setLayoutProperty(id, "visibility", enabled ? "visible" : "none")
);
};
private addLayers() {
const map = this.map;
if (!map) return;
Object.entries(citySources()).forEach(([id, source]) => map.addSource(id, source));
const firstBusinessLayer = findCitySceneInsertionLayerId(map.getStyle().layers);
CITY_LAYERS.forEach((layer) => map.addLayer(layer, firstBusinessLayer));
this.attribution = new maplibregl.AttributionControl({
compact: true,
customAttribution:
'<a href="/city-model/ATTRIBUTION.md" target="_blank" rel="noopener noreferrer">Qian Shi et al. · 数据来源与许可</a>'
});
map.addControl(this.attribution, "bottom-left");
this.initialized = true;
}
onRemove() {
const map = this.map;
map?.off("pitchend", this.syncVisibility);
if (this.attribution && map?.hasControl(this.attribution)) map.removeControl(this.attribution);
if (map && this.initialized) {
[...CITY_LAYERS].reverse().forEach(({ id }) => {
if (map.getLayer(id)) map.removeLayer(id);
});
Object.keys(citySources()).forEach((id) => {
if (map.getSource(id)) map.removeSource(id);
});
}
this.map = undefined;
}
}
/** Scene is optional; absent local data must not break the operational map. */
export function installCityScene(map: MapLibreMap): () => void {
const abort = new AbortController();
let control: CitySceneControl | undefined;
void fetch(`${CITY_ROOT}/manifest.json`, { signal: abort.signal })
.then(async (response) => {
if (!response.ok) return;
const manifest = cityManifestSchema.safeParse(await response.json());
if (!manifest.success || abort.signal.aborted) return;
control = new CitySceneControl();
control.attach(map);
})
.catch(() => {
/* Optional scene is not deployed on every installation. */
});
return () => {
abort.abort();
control?.onRemove();
};
}
@@ -0,0 +1,56 @@
import type { LayerSpecification } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import {
CITY_LAYERS,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
const manifest = {
schema_version: 1,
name: "奉贤",
bbox: [121.34, 30.68, 121.85, 31.02],
building_count: 1,
pipe_count: 1,
asset_count: 1,
notice: "参考模型",
attribution: "OSM"
};
describe("city scene geographic contract", () => {
it("inserts buildings above every basemap layer and below business layers", () => {
const layers = [
{ id: "background", type: "background" },
{ id: "mapbox-light", type: "raster", source: "mapbox-light" },
{ id: "tianditu-vector", type: "raster", source: "tianditu-vector" },
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation"
},
{ id: "pipes", type: "line", source: "pipes", "source-layer": "pipes" }
] satisfies LayerSpecification[];
expect(findCitySceneInsertionLayerId(layers)).toBe("pipes");
});
it("adds only the building background and leaves business layers to their existing controls", () => {
expect(Object.keys(citySources())).toEqual(["fengxian-city-buildings"]);
expect(CITY_LAYERS.map((layer) => layer.id)).toEqual(["fengxian-city-buildings"]);
});
it("accepts WGS84 scene bounds", () => {
expect(cityManifestSchema.safeParse(manifest).success).toBe(true);
});
it("rejects 3857 meters passed as longitude and latitude", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [13508801, 3608163, 13555650, 3633685] })
.success
).toBe(false);
});
it("rejects reversed extents and malformed counts", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [121.85, 30.68, 121.34, 31.02] }).success
).toBe(false);
expect(cityManifestSchema.safeParse({ ...manifest, pipe_count: -1 }).success).toBe(false);
});
});
@@ -0,0 +1,60 @@
import type { LayerSpecification, SourceSpecification } from "maplibre-gl";
import { z } from "zod";
import { BASE_LAYER_IDS } from "./map-control-config";
import { MAP_STYLE_TOKENS as colors } from "./map-colors";
export const CITY_ROOT = "/city-model";
export const CITY_PREFIX = "fengxian-city-";
export function findCitySceneInsertionLayerId(layers: readonly LayerSpecification[]) {
const basemapLayerIds = new Set<string>(BASE_LAYER_IDS);
return layers.find((layer) => layer.id !== "background" && !basemapLayerIds.has(layer.id))?.id;
}
export const cityManifestSchema = z
.object({
schema_version: z.literal(1),
name: z.string(),
bbox: z.tuple([
z.number().min(-180).max(180),
z.number().min(-90).max(90),
z.number().min(-180).max(180),
z.number().min(-90).max(90)
]),
building_count: z.number().int().nonnegative(),
pipe_count: z.number().int().nonnegative(),
asset_count: z.number().int().nonnegative(),
notice: z.string(),
attribution: z.string()
})
.refine(({ bbox: [w, s, e, n] }) => w < e && s < n, "Invalid scene bounds");
export function citySources(): Record<string, SourceSpecification> {
return {
[CITY_PREFIX + "buildings"]: {
type: "vector",
url: `pmtiles://${window.location.origin}${CITY_ROOT}/buildings.pmtiles`,
minzoom: 12,
maxzoom: 16,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a> · <a href="https://docs.overturemaps.org/attribution/">Overture Maps</a>',
promoteId: "id"
}
};
}
export const CITY_LAYERS: LayerSpecification[] = [
{
id: CITY_PREFIX + "buildings",
source: CITY_PREFIX + "buildings",
"source-layer": "buildings",
type: "fill-extrusion",
minzoom: 12,
paint: {
"fill-extrusion-color": colors.canvas.secondary,
"fill-extrusion-height": ["to-number", ["get", "height_m"], 6],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": 1,
"fill-extrusion-vertical-gradient": true
}
}
];
@@ -3,30 +3,61 @@ import { describe, expect, it } from "vitest";
import { waterNetworkLayers } from "./layers"; import { waterNetworkLayers } from "./layers";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
import { import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY, INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS, MAP_LEGEND_ITEMS,
applyBaseLayerVisibility,
createLayerControlItems, createLayerControlItems,
createVisibleMapLegendItems, createVisibleMapLegendItems,
getWorkbenchLayerIds getWorkbenchLayerIds
} from "./map-control-config"; } from "./map-control-config";
describe("workbench supply layer controls", () => { describe("workbench supply layer controls", () => {
it("switches Tianditu basemaps as base and annotation layer pairs", () => {
const visibility = new Map<string, string>();
const map = {
getLayer: () => ({}),
setLayoutProperty: (layerId: string, _property: string, value: string) => {
visibility.set(layerId, value);
}
} as unknown as MapLibreMap;
expect(BASE_LAYER_OPTIONS.map((option) => option.id)).toEqual([
"mapbox-light",
"mapbox-satellite",
"tianditu-vector",
"tianditu-image",
"none"
]);
applyBaseLayerVisibility(map, "tianditu-image");
expect(visibility.get("tianditu-image")).toBe("visible");
expect(visibility.get("tianditu-image-annotation")).toBe("visible");
expect(visibility.get("tianditu-vector")).toBe("none");
expect(visibility.get("tianditu-vector-annotation")).toBe("none");
expect(visibility.get("mapbox-light")).toBe("none");
expect(visibility.get("mapbox-satellite")).toBe("none");
});
it("creates controls only for available supply system sources", () => { it("creates controls only for available supply system sources", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, { const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
fx_20200423_pipes: 215_797, fx_20200423_pipes: 215_797,
fx_20200811_dn100_pipes: 122_686 fx_20200811_dn100_pipes: 122_686
}); });
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([ expect(items.map((item) => item.id)).toEqual(
"pipes", expect.arrayContaining([
"junctions", "pipes",
"scada-pressure", "junctions",
"scada-flow", "scada-pressure",
"water_quality_monitoring_points", "scada-flow",
"fx_20200423_pipes", "water_quality_monitoring_points",
"fx_20200811_dn100_pipes", "fx_20200423_pipes",
"simulation" "fx_20200811_dn100_pipes",
])); "simulation"
])
);
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({ expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
group: "source-gis", group: "source-gis",
groupLabel: "GIS 原始数据", groupLabel: "GIS 原始数据",
@@ -99,15 +130,25 @@ describe("workbench supply layer controls", () => {
"source-non-residential-meter", "source-non-residential-meter",
"impact-area" "impact-area"
]); ]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" }); expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "reservoir")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "reservoir")).toMatchObject({
label: "水库", label: "水库",
imageSrc: "/map/reservoir.png" imageSrc: "/map/reservoir.png"
}); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" }); expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" }); imageSrc: "/map/pump.png"
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" }); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" }); expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({
imageSrc: "/map/tank.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({
imageSrc: "/map/scada-flow.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({
imageSrc: "/map/scada-pressure.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.state.inactive, color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5, lineWidth: 2.5,
@@ -133,7 +174,9 @@ describe("workbench supply layer controls", () => {
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
imageSrc: "/map/gis-hydrant.svg" imageSrc: "/map/gis-hydrant.svg"
}); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")).toMatchObject({ expect(
MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")
).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg" imageSrc: "/map/gis-non-residential-meter.svg"
}); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
@@ -147,32 +190,27 @@ describe("workbench supply layer controls", () => {
const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY); const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id); const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
expect(initialLegendIds).toEqual(expect.arrayContaining([ expect(initialLegendIds).toEqual(
"major-pipe", expect.arrayContaining(["major-pipe", "valve", "scada-flow", "water-quality-monitoring"])
"valve", );
"scada-flow", expect(initialLegendIds).not.toEqual(
"water-quality-monitoring" expect.arrayContaining(["source-major-pipe", "source-inventory-pipe", "impact-area"])
])); );
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
"source-major-pipe",
"source-inventory-pipe",
"impact-area"
]));
const sourcePipeItems = createLayerControlItems({ const sourcePipeItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY, ...INITIAL_LAYER_VISIBILITY,
fx_20200423_pipes: true fx_20200423_pipes: true
}); });
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map( const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map((item) => item.id);
(item) => item.id
);
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([ expect(sourcePipeLegendIds).toEqual(
"source-major-pipe", expect.arrayContaining([
"source-medium-pipe", "source-major-pipe",
"source-minor-pipe", "source-medium-pipe",
"source-unknown-pipe" "source-minor-pipe",
])); "source-unknown-pipe"
])
);
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe"); expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
const reducedItems = createLayerControlItems({ const reducedItems = createLayerControlItems({
+184 -49
View File
@@ -11,11 +11,7 @@ import {
SUPPLY_LAYER_CATALOG, SUPPLY_LAYER_CATALOG,
type WaterNetworkSourceId type WaterNetworkSourceId
} from "./sources"; } from "./sources";
import { import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
SCADA_ICON_PATHS,
SCADA_LAYER_CONTROL_ITEMS,
SCADA_LAYER_GROUPS
} from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons"; import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = { export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
@@ -33,18 +29,17 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = { export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries( ...Object.fromEntries(
SUPPLY_LAYER_CATALOG SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
.filter((layer) => layer.id !== "scada") layer.id,
.map((layer) => [layer.id, layer.initialVisible]) layer.initialVisible
])
), ),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])), ...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false simulation: false
}; };
const SOURCE_CONTROL_LABELS = Object.fromEntries( const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [layer.id, layer.label])
.filter((layer) => layer.available)
.map((layer) => [layer.id, layer.label])
) as Record<WaterNetworkSourceId, string>; ) as Record<WaterNetworkSourceId, string>;
type LayerControlPresentation = { type LayerControlPresentation = {
@@ -55,7 +50,10 @@ type LayerControlPresentation = {
visual: MapLayerControlVisual; visual: MapLayerControlVisual;
}; };
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = { const LAYER_CONTROL_PRESENTATION: Record<
Exclude<WaterNetworkSourceId, "scada">,
LayerControlPresentation
> = {
pipes: { pipes: {
description: "按管径分级", description: "按管径分级",
minZoom: 8.5, minZoom: 8.5,
@@ -258,14 +256,70 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "pipes", layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP ...MODEL_PIPE_LEGEND_GROUP
}, },
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot", layerControlId: "junctions", ...MODEL_FACILITY_LEGEND_GROUP }, {
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve, layerControlId: "valves", ...MODEL_FACILITY_LEGEND_GROUP }, id: "junction",
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir, layerControlId: "reservoirs", ...MODEL_FACILITY_LEGEND_GROUP }, label: "节点",
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump, layerControlId: "pumps", ...MODEL_FACILITY_LEGEND_GROUP }, color: MAP_STYLE_TOKENS.supply.junction,
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank, layerControlId: "tanks", ...MODEL_FACILITY_LEGEND_GROUP }, shape: "dot",
{ id: "scada-flow", label: "流量监测", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow, layerControlId: "scada-flow", ...MONITORING_LEGEND_GROUP }, layerControlId: "junctions",
{ id: "scada-pressure", label: "压力监测", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure, layerControlId: "scada-pressure", ...MONITORING_LEGEND_GROUP }, ...MODEL_FACILITY_LEGEND_GROUP
{ id: "water-quality-monitoring", label: "水质监测", color: MAP_STYLE_TOKENS.supply.waterQuality, imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality, layerControlId: "water_quality_monitoring_points", ...MONITORING_LEGEND_GROUP }, },
{
id: "valve",
label: "阀门",
color: MAP_STYLE_TOKENS.supply.valve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.valve,
layerControlId: "valves",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "reservoir",
label: "水库",
color: MAP_STYLE_TOKENS.supply.reservoir,
imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir,
layerControlId: "reservoirs",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "pump",
label: "水泵",
color: MAP_STYLE_TOKENS.supply.pump,
imageSrc: SUPPLY_ASSET_ICON_PATHS.pump,
layerControlId: "pumps",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "tank",
label: "水箱",
color: MAP_STYLE_TOKENS.supply.tank,
imageSrc: SUPPLY_ASSET_ICON_PATHS.tank,
layerControlId: "tanks",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "scada-flow",
label: "流量监测",
color: MAP_STYLE_TOKENS.supply.scadaFlow,
imageSrc: SCADA_ICON_PATHS.flow,
layerControlId: "scada-flow",
...MONITORING_LEGEND_GROUP
},
{
id: "scada-pressure",
label: "压力监测",
color: MAP_STYLE_TOKENS.supply.scadaPressure,
imageSrc: SCADA_ICON_PATHS.pressure,
layerControlId: "scada-pressure",
...MONITORING_LEGEND_GROUP
},
{
id: "water-quality-monitoring",
label: "水质监测",
color: MAP_STYLE_TOKENS.supply.waterQuality,
imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality,
layerControlId: "water_quality_monitoring_points",
...MONITORING_LEGEND_GROUP
},
{ {
id: "source-major-pipe", id: "source-major-pipe",
label: "DN600 及以上", label: "DN600 及以上",
@@ -311,36 +365,101 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "fx_20200811_dn100_pipes", layerControlId: "fx_20200811_dn100_pipes",
...SOURCE_AUGUST_PIPE_LEGEND_GROUP ...SOURCE_AUGUST_PIPE_LEGEND_GROUP
}, },
{ id: "source-valve", label: "GIS 阀门", color: MAP_STYLE_TOKENS.supply.sourceValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve, layerControlId: "fx_20200423_valves", ...SOURCE_FACILITY_LEGEND_GROUP }, {
{ id: "source-drain-valve", label: "排水阀", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve, layerControlId: "fx_20200423_drain_valves", ...SOURCE_FACILITY_LEGEND_GROUP }, id: "source-valve",
{ id: "source-flow-meter", label: "流量仪", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter, layerControlId: "fx_20200423_flow_meters", ...SOURCE_FACILITY_LEGEND_GROUP }, label: "GIS 阀门",
{ id: "source-hydrant", label: "消火栓", color: MAP_STYLE_TOKENS.supply.sourceHydrant, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant, layerControlId: "fx_20200423_hydrants", ...SOURCE_FACILITY_LEGEND_GROUP }, color: MAP_STYLE_TOKENS.supply.sourceValve,
{ id: "source-connection", label: "接水点", color: MAP_STYLE_TOKENS.supply.sourceConnection, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection, layerControlId: "fx_20200423_service_connections", ...SOURCE_FACILITY_LEGEND_GROUP }, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve,
{ id: "source-customer-meter", label: "用户水表", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter, layerControlId: "fx_20200423_customer_meters", ...SOURCE_FACILITY_LEGEND_GROUP }, layerControlId: "fx_20200423_valves",
{ id: "source-non-residential-meter", label: "非居民水表", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter, layerControlId: "fx_20200423_non_residential_meters", ...SOURCE_FACILITY_LEGEND_GROUP }, ...SOURCE_FACILITY_LEGEND_GROUP
{ id: "impact-area", label: "影响范围", description: "事故或分析结果", color: MAP_STYLE_TOKENS.state.incident, shape: "area", layerControlId: "simulation", ...ANALYSIS_LEGEND_GROUP } },
{
id: "source-drain-valve",
label: "排水阀",
color: MAP_STYLE_TOKENS.supply.sourceDrainValve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve,
layerControlId: "fx_20200423_drain_valves",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-flow-meter",
label: "流量仪",
color: MAP_STYLE_TOKENS.supply.sourceFlowMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter,
layerControlId: "fx_20200423_flow_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-hydrant",
label: "消火栓",
color: MAP_STYLE_TOKENS.supply.sourceHydrant,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant,
layerControlId: "fx_20200423_hydrants",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-connection",
label: "接水点",
color: MAP_STYLE_TOKENS.supply.sourceConnection,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection,
layerControlId: "fx_20200423_service_connections",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-customer-meter",
label: "用户水表",
color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter,
layerControlId: "fx_20200423_customer_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-non-residential-meter",
label: "非居民水表",
color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
layerControlId: "fx_20200423_non_residential_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "impact-area",
label: "影响范围",
description: "事故或分析结果",
color: MAP_STYLE_TOKENS.state.incident,
shape: "area",
layerControlId: "simulation",
...ANALYSIS_LEGEND_GROUP
}
]; ];
export function createVisibleMapLegendItems( export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[] layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
) { ) {
const visibleLayerIds = new Set( const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
layerItems.filter((item) => item.visible).map((item) => item.id)
);
return MAP_LEGEND_ITEMS.filter((item) => return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
); );
} }
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const; export const BASE_LAYER_GROUPS = {
"mapbox-light": ["mapbox-light"],
"mapbox-satellite": ["mapbox-satellite"],
"tianditu-vector": ["tianditu-vector", "tianditu-vector-annotation"],
"tianditu-image": ["tianditu-image", "tianditu-image-annotation"]
} as const;
export const BASE_LAYER_IDS = Object.values(BASE_LAYER_GROUPS).flat();
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) { export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
const activeLayerIds = new Set<string>(
BASE_LAYER_GROUPS[activeBaseLayerId as keyof typeof BASE_LAYER_GROUPS] ?? []
);
BASE_LAYER_IDS.forEach((layerId) => { BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) { if (map.getLayer(layerId)) {
map.setLayoutProperty( map.setLayoutProperty(
layerId, layerId,
"visibility", "visibility",
activeBaseLayerId === layerId ? "visible" : "none" activeLayerIds.has(layerId) ? "visible" : "none"
); );
} }
}); });
@@ -359,6 +478,18 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
description: "卫星街道", description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200" swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
}, },
{
id: "tianditu-vector",
label: "天地图矢量",
description: "矢量与中文注记",
swatchClassName: "bg-gradient-to-br from-sky-50 via-stone-100 to-emerald-100"
},
{
id: "tianditu-image",
label: "天地图影像",
description: "影像与中文注记",
swatchClassName: "bg-gradient-to-br from-slate-950 via-emerald-800 to-stone-300"
},
{ {
id: "none", id: "none",
label: "无底图", label: "无底图",
@@ -390,20 +521,23 @@ export function createLayerControlItems(
groupLabel: "管网模型", groupLabel: "管网模型",
groupDescription: "计算模型与基础设施" groupDescription: "计算模型与基础设施"
})), })),
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({ ...(available.has("scada")
id: item.id, ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
label: item.label, id: item.id,
description: item.description, label: item.label,
minZoom: 8.5, description: item.description,
visual: { minZoom: 8.5,
kind: "image" as const, visual: {
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow kind: "image" as const,
}, imageSrc:
visible: layerVisibility[item.id], item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
group: "monitoring", },
groupLabel: "监测点", visible: layerVisibility[item.id],
groupDescription: "在线监测与分析选点" group: "monitoring",
})) : []), groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
}))
: []),
...monitoringItems.map((layer) => ({ ...monitoringItems.map((layer) => ({
id: layer.id, id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id], label: SOURCE_CONTROL_LABELS[layer.id],
@@ -443,8 +577,9 @@ export function getWorkbenchLayerIds(
groupId: string groupId: string
) { ) {
if (groupId in SCADA_LAYER_GROUPS) { if (groupId in SCADA_LAYER_GROUPS) {
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS] return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS].filter((layerId) =>
.filter((layerId) => map.getLayer(layerId)); map.getLayer(layerId)
);
} }
if (isWaterNetworkSourceId(groupId)) { if (isWaterNetworkSourceId(groupId)) {
return map return map
+43 -2
View File
@@ -24,8 +24,9 @@ describe("createWaterNetworkSources", () => {
}); });
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points"); expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters"); expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points")) expect(
.toMatchObject({ available: true, availability: "probe", group: "monitoring" }); SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points")
).toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9); expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({ expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point", geometry: "point",
@@ -63,6 +64,46 @@ describe("createWaterNetworkSources", () => {
}); });
}); });
it("creates paired Tianditu vector and imagery sources only when configured", () => {
expect(createBaseStyle().sources).not.toHaveProperty("tianditu-vector");
const style = createBaseStyle(undefined, "test-tianditu-token");
expect(style.sources["tianditu-vector"]).toMatchObject({
type: "raster",
tileSize: 256,
maxzoom: 18,
tiles: [expect.stringContaining("/vec_w/wmts?")]
});
expect(style.sources["tianditu-vector-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cva.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=img.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cia.*tk=test-tianditu-token/)]
});
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "visible"
});
expect(
style.layers?.find((layer) => layer.id === "tianditu-vector-annotation")?.layout
).toMatchObject({ visibility: "visible" });
expect(style.layers?.find((layer) => layer.id === "tianditu-image")?.layout).toMatchObject({
visibility: "none"
});
});
it("keeps Tianditu hidden when Mapbox is the initial basemap", () => {
const style = createBaseStyle("mapbox-token", "tianditu-token");
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "none"
});
expect(style.layers?.find((layer) => layer.id === "mapbox-light")?.layout).toBeUndefined();
});
it("creates only the requested GeoServer vector sources", () => { it("creates only the requested GeoServer vector sources", () => {
const sources = createWaterNetworkSources(["pipes", "junctions"]); const sources = createWaterNetworkSources(["pipes", "junctions"]);
+63 -6
View File
@@ -2,10 +2,7 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
import { import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada"; import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = { export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -280,7 +277,10 @@ export function isAvailableWaterNetworkSourceId(
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value); return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
} }
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>; export function createWaterNetworkSources(): Record<
GeoServerWaterNetworkSourceId,
VectorSourceSpecification
>;
export function createWaterNetworkSources( export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[] sourceIds: readonly GeoServerWaterNetworkSourceId[]
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>; ): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
@@ -312,7 +312,11 @@ function createGeoServerVectorSource(
}; };
} }
export function createBaseStyle(mapboxToken?: string): StyleSpecification { function createTiandituTileUrl(layer: "vec" | "cva" | "img" | "cia", token: string) {
return `https://t0.tianditu.gov.cn/${layer}_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layer}&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${token}`;
}
export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = { const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: { [SCADA_SOURCE_ID]: {
type: "geojson", type: "geojson",
@@ -379,6 +383,59 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
}); });
} }
if (tiandituToken) {
const tiandituSources = [
["tianditu-vector", "vec"],
["tianditu-vector-annotation", "cva"],
["tianditu-image", "img"],
["tianditu-image-annotation", "cia"]
] as const;
tiandituSources.forEach(([sourceId, layer]) => {
sources[sourceId] = {
type: "raster",
tiles: [createTiandituTileUrl(layer, tiandituToken)],
tileSize: 256,
maxzoom: 18,
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>'
};
});
const vectorVisible = mapboxToken ? "none" : "visible";
layers.push(
{
id: "tianditu-vector",
type: "raster",
source: "tianditu-vector",
layout: { visibility: vectorVisible },
paint: { "raster-opacity": 0.9 }
},
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation",
layout: { visibility: vectorVisible }
},
{
id: "tianditu-image",
type: "raster",
source: "tianditu-image",
layout: { visibility: "none" },
paint: {
"raster-opacity": 0.9,
"raster-saturation": -0.12,
"raster-contrast": 0.06
}
},
{
id: "tianditu-image-annotation",
type: "raster",
source: "tianditu-image-annotation",
layout: { visibility: "none" }
}
);
}
return { return {
version: 8, version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
@@ -147,6 +147,7 @@ describe("PeripheralSampleClient", () => {
work_order_id: "50000000-0000-4000-8000-000000000001", project_id: "project-1", revision: 2, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: "project-1", revision: 2,
title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const, title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const,
priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const, priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const,
work_order_type: "general" as const, location: null, scenario_code: null,
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z", created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
created_by: "user", updated_by: "user" created_by: "user", updated_by: "user"
}; };
@@ -446,6 +446,8 @@ export interface components {
CreateWorkOrder: { CreateWorkOrder: {
/** Assignee */ /** Assignee */
assignee?: string | null; assignee?: string | null;
/** Location */
location?: string | null;
/** /**
* Priority * Priority
* @default normal * @default normal
@@ -459,6 +461,8 @@ export interface components {
request_id: string; request_id: string;
/** Scheduled At */ /** Scheduled At */
scheduled_at?: string | null; scheduled_at?: string | null;
/** Scenario Code */
scenario_code?: string | null;
/** /**
* Status * Status
* @default draft * @default draft
@@ -469,6 +473,8 @@ export interface components {
summary: string; summary: string;
/** Title */ /** Title */
title: string; title: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
}; };
/** DeleteResource */ /** DeleteResource */
DeleteResource: { DeleteResource: {
@@ -1066,6 +1072,8 @@ export interface components {
created_at: string; created_at: string;
/** Created By */ /** Created By */
created_by: string; created_by: string;
/** Location */
location?: string | null;
/** /**
* Origin * Origin
* @constant * @constant
@@ -1085,6 +1093,8 @@ export interface components {
revision: number; revision: number;
/** Scheduled At */ /** Scheduled At */
scheduled_at: string | null; scheduled_at: string | null;
/** Scenario Code */
scenario_code?: string | null;
/** /**
* Status * Status
* @enum {string} * @enum {string}
@@ -1106,6 +1116,8 @@ export interface components {
* Format: uuid * Format: uuid
*/ */
work_order_id: string; work_order_id: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
}; };
/** WorkOrderPage */ /** WorkOrderPage */
WorkOrderPage: { WorkOrderPage: {
@@ -2,17 +2,17 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";
import { import {
Ban, Ban,
CalendarClock, CalendarClock,
Check, CheckCircle2,
CircleDot, CircleDot,
ClipboardList, ClipboardList,
Clock3,
Database, Database,
GitBranch,
LoaderCircle, LoaderCircle,
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Sparkles,
Trash2, Trash2,
Wrench,
X X
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -33,11 +33,12 @@ import type {
type ResourceView = "orders" | "data"; type ResourceView = "orders" | "data";
type Inventory = { workOrders: number; testData: number }; type Inventory = { workOrders: number; testData: number };
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange }: { export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange, onAgentTrigger }: {
client: PeripheralSampleClient; client: PeripheralSampleClient;
canWrite: boolean; canWrite: boolean;
capabilities: PeripheralContext["managed_resources"]; capabilities: PeripheralContext["managed_resources"];
onInventoryChange?: (inventory: Inventory) => void; onInventoryChange?: (inventory: Inventory) => void;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) { }) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]); const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]); const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
@@ -81,9 +82,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
}; };
return <WorkbenchSection return <WorkbenchSection
title="外围任务编排" title="外围工单与测试数据"
description="一个任务包含多个子任务,每个子任务按派发、执行、复令推进。" description="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
icon={<GitBranch size={15} />} icon={<ClipboardList size={15} />}
tone="teal" tone="teal"
bodyClassName="space-y-3" bodyClassName="space-y-3"
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>} actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
@@ -92,12 +93,12 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
{capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null} {capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null}
{capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null} {capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1"> <div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<GitBranch size={13} />} onClick={() => setView("orders")}>任务结构</ResourceSwitch> <ResourceSwitch active={view === "orders"} count={orders.length} icon={<ClipboardList size={13} />} onClick={() => setView("orders")}>工单列表</ResourceSwitch>
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch> <ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
</div> </div>
{view === "orders" ? ( {view === "orders" ? (
capabilities.work_orders === "available" capabilities.work_orders === "available"
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} /> ? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} onAgentTrigger={onAgentTrigger} />
: null : null
) : ( ) : (
capabilities.test_data === "available" capabilities.test_data === "available"
@@ -133,8 +134,9 @@ function StoragePending({ resource, tables }: { resource: string; tables: string
</WorkbenchNotice>; </WorkbenchNotice>;
} }
function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps & { function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: ResourceProps & {
orders: PeripheralWorkOrder[]; orders: PeripheralWorkOrder[];
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) { }) {
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null); const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
@@ -142,6 +144,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
const [summary, setSummary] = useState(""); const [summary, setSummary] = useState("");
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft"); const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal"); const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{
message: string;
workOrder: PeripheralWorkOrder;
} | null>(null);
const clear = () => { const clear = () => {
setEditing(null); setEditing(null);
@@ -163,29 +169,67 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
setStatus(item.status); setStatus(item.status);
setPriority(item.priority); setPriority(item.priority);
}; };
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
if (!onAgentTrigger) {
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
return;
}
try {
await onAgentTrigger(workOrder);
setAgentTriggerFailure(null);
} catch (error) {
setAgentTriggerFailure({ message: message(error), workOrder });
}
};
const createWaterQualityComplaint = () => void mutate("create-water-quality-order", async () => {
const workOrder = await client.createWorkOrder({
title: "居民反映自来水有异味",
summary: "演示小区多户居民反映晨间用水有异味,请核查上游水质及二次供水环节。",
status: "open",
priority: "high",
assignee: null,
scheduled_at: null,
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: "WQ-SECONDARY-SUPPLY-001"
});
await triggerAgent(workOrder);
});
return <div> return <div>
<div className="flex items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">任务层级</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">管理主任务下的子任务及其阶段状态。</p></div> <div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">测试工单</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张工单独立展示派发、执行和复令步骤,步骤状态互不混用。</p></div>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增子任务</Button> <div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={createWaterQualityComplaint}>
{busy === "create-water-quality-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
水质投诉演示
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
</div>
</div> </div>
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200"> {agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
<EditorHeading title={editing ? "编辑子任务" : "新增子任务"} onClose={clear} /> <p>{agentTriggerFailure.message}</p>
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
{busy === "retry-agent-trigger" ? <LoaderCircle className="animate-spin" /> : <RefreshCw size={13} />}重试触发
</Button>
</WorkbenchNotice> : null}
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑工单" : "新增工单"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label> <label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label> <label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="子任务状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[ <div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" } { value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[ ]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" } { value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
]} /></div> ]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => { <div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false }); if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null }); else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null, work_order_type: "general", location: null, scenario_code: null });
clear(); clear();
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建子任务"}</Button> })}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建工单"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div> <Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null} </div> : null}
<TaskHierarchy <WorkOrderList
orders={orders} orders={orders}
canWrite={canWrite} canWrite={canWrite}
busy={busy} busy={busy}
@@ -198,36 +242,23 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
</div>; </div>;
} }
function TaskHierarchy({ orders, canWrite, busy, onEdit, onDelete }: { function WorkOrderList({ orders, canWrite, busy, onEdit, onDelete }: {
orders: PeripheralWorkOrder[]; orders: PeripheralWorkOrder[];
canWrite: boolean; canWrite: boolean;
busy: string | null; busy: string | null;
onEdit: (item: PeripheralWorkOrder) => void; onEdit: (item: PeripheralWorkOrder) => void;
onDelete: (item: PeripheralWorkOrder) => void; onDelete: (item: PeripheralWorkOrder) => void;
}) { }) {
const completed = orders.filter((item) => item.status === "completed").length; return <section aria-label="工单列表" className="mt-3">
const parentStatus = taskStatus(orders); {orders.length ? <ol className="space-y-2">
return <section aria-label="任务层级" className="mt-3 overflow-hidden rounded-xl bg-slate-50/80 ring-1 ring-inset ring-slate-200"> {orders.map((item, index) => <li key={item.work_order_id}>
<header className="border-b border-cyan-100 bg-cyan-50/70 px-3.5 py-3 text-slate-900"> <WorkOrderCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-xs ring-1 ring-inset ring-cyan-100"><ClipboardList size={16} /></span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-semibold text-cyan-700">任务</span><StatusBadge tone={parentStatus.tone}>{parentStatus.label}</StatusBadge></div>
<h5 className="mt-1 truncate text-sm font-semibold">外围合成数据核查</h5>
<p className="mt-1 text-[10px] text-slate-500"><span className="font-mono">DEMO-WO-001</span> · {orders.length} 个子任务 · {completed}/{orders.length} 完成</p>
</div>
</div>
</header>
{orders.length ? <ol className="relative space-y-2 p-3 before:absolute before:bottom-8 before:left-[25px] before:top-3 before:w-px before:bg-cyan-200">
{orders.map((item, index) => <li key={item.work_order_id} className="relative pl-7">
<span className="absolute left-0 top-3 z-10 grid size-5 place-items-center rounded-full bg-white text-[10px] font-semibold tabular-nums text-cyan-800 ring-1 ring-inset ring-cyan-300">{index + 1}</span>
<SubtaskCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
</li>)} </li>)}
</ol> : <div className="p-3"><WorkbenchEmptyState icon={<Wrench size={15} />} title="当前任务还没有子任务" description="新增子任务后,可在这里查看每项任务的派发、执行和复令状态。" /></div>} </ol> : <WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="新增工单后,可在这里查看接口返回的工单字段和状态。" />}
</section>; </section>;
} }
function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: { function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
item: PeripheralWorkOrder; item: PeripheralWorkOrder;
index: number; index: number;
canWrite: boolean; canWrite: boolean;
@@ -235,47 +266,75 @@ function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
return <article className="rounded-xl bg-white p-3 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"> return <article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">子任务 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge></div> <div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>{item.work_order_type === "water_quality_complaint" ? <StatusBadge tone="warning">水质投诉</StatusBadge> : null}</div>
<h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6> <h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p> <p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p>
{item.location ? <p className="mt-1 text-[10px] text-slate-400">位置:{item.location}</p> : null}
</div> </div>
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div> <div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
</div> </div>
<StageProgress status={item.status} title={item.title} /> <WorkOrderSteps status={item.status} title={item.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500"> <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span><span>r{item.revision}</span>{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null} <span>{priorityLabel(item.priority)}优先级</span>
<span className="max-w-full truncate font-mono" title={item.work_order_id}>工单号 {item.work_order_id}</span>
<span>r{item.revision}</span>
{item.assignee ? <span>处理人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
</div> </div>
</article>; </article>;
} }
type StageStatus = "done" | "current" | "pending" | "skipped"; type WorkOrderStepStatus = "done" | "current" | "pending" | "skipped";
function StageProgress({ status, title }: { status: PeripheralWorkOrder["status"]; title: string }) { const WORK_ORDER_STEPS = [
const statuses = stageStatuses(status); { id: "dispatch", title: "派发", description: "确认处理班组与计划窗口" },
const stages = ["派发", "执行", "复令"]; { id: "execution", title: "执行", description: "按工单要求处置并留痕" },
return <div className="mt-3 rounded-lg bg-slate-50 px-2.5 py-2" aria-label={`子任务阶段 ${title}`}> { id: "reply", title: "复令", description: "回填结果并完成调度复核" }
<p className="mb-2 text-[10px] font-semibold text-slate-500">子任务阶段</p> ] as const;
<ol className="grid grid-cols-3">
{stages.map((stage, index) => { function WorkOrderSteps({ status, title }: {
const stageStatus = statuses[index]; status: PeripheralWorkOrder["status"];
return <li key={stage} className="relative min-w-0 text-center"> title: string;
{index < stages.length - 1 ? <span className={cn("absolute left-[calc(50%+9px)] right-[calc(-50%+9px)] top-2 h-px", stageStatus === "done" ? "bg-cyan-400" : "bg-slate-300")} aria-hidden="true" /> : null} }) {
<span className={cn( const statuses = workOrderStepStatuses(status);
"relative z-10 mx-auto grid size-4 place-items-center rounded-full text-[9px] font-semibold", const completed = statuses.filter((item) => item === "done").length;
stageStatus === "done" && "bg-cyan-700 text-white",
stageStatus === "current" && "bg-white text-cyan-700 ring-2 ring-inset ring-cyan-500", return <section aria-label={`工单步骤 ${title}`} className="mt-3 border-t border-slate-200/80 pt-2.5">
stageStatus === "pending" && "bg-white text-slate-400 ring-1 ring-inset ring-slate-300", <div className="mb-2 flex items-center justify-between gap-2">
stageStatus === "skipped" && "bg-slate-200 text-slate-500" <p className="text-[10px] font-semibold text-slate-500">工单步骤</p>
)}>{stageStatus === "done" ? <Check size={10} /> : stageStatus === "current" ? <CircleDot size={10} /> : stageStatus === "skipped" ? <Ban size={9} /> : index + 1}</span> <span className="text-[10px] tabular-nums text-slate-400">{completed}/{WORK_ORDER_STEPS.length} 完成</span>
<p className="mt-1 truncate text-[10px] font-semibold text-slate-700">{stage}</p> </div>
<p className={cn("truncate text-[9px]", stageStatus === "current" ? "font-semibold text-cyan-700" : stageStatus === "done" ? "text-cyan-700" : "text-slate-400")}>{stageStatusLabel(stageStatus)}</p> <ol className="grid gap-1.5">
{WORK_ORDER_STEPS.map((step, index) => {
const stepStatus = statuses[index];
return <li key={step.id} className="grid min-w-0 grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-lg bg-white px-2 py-2 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
<WorkOrderStepIcon status={stepStatus} step={index + 1} />
<span className="min-w-0">
<span className="block text-[11px] font-semibold text-slate-800">{step.title}</span>
<span className="block truncate text-[10px] text-slate-400">{step.description}</span>
</span>
<StatusBadge tone={workOrderStepTone(stepStatus)}>{workOrderStepStatusLabel(stepStatus)}</StatusBadge>
</li>; </li>;
})} })}
</ol> </ol>
</div>; </section>;
}
function WorkOrderStepIcon({ status, step }: { status: WorkOrderStepStatus; step: number }) {
const className = cn(
"grid size-6 place-items-center rounded-full",
status === "done" && "bg-emerald-600 text-white",
status === "current" && "bg-cyan-50 text-cyan-700 ring-2 ring-inset ring-cyan-500",
status === "pending" && "bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200",
status === "skipped" && "bg-slate-200 text-slate-500"
);
if (status === "done") return <span className={className}><CheckCircle2 size={13} /></span>;
if (status === "current") return <span className={className}><CircleDot size={13} /></span>;
if (status === "skipped") return <span className={className}><Ban size={12} /></span>;
return <span className={className}><Clock3 size={12} /><span className="sr-only">步骤 {step}</span></span>;
} }
function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & { function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
@@ -316,7 +375,7 @@ function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
return <div> return <div>
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} /> <ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200"> {editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} /> <EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label> <label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div> <div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
@@ -351,7 +410,7 @@ function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAd
} }
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) { function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-cyan-950">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>; return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
} }
type ResourceProps = { type ResourceProps = {
@@ -366,21 +425,15 @@ function formatTimestamp(value: string) { return value.replace("T", " ").replace
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; } function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; } function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; } function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
function taskStatus(orders: PeripheralWorkOrder[]): { label: string; tone: StatusTone } { function workOrderStepStatuses(status: PeripheralWorkOrder["status"]): [WorkOrderStepStatus, WorkOrderStepStatus, WorkOrderStepStatus] {
if (!orders.length) return { label: "待编排", tone: "neutral" };
if (orders.every((item) => item.status === "completed")) return { label: "已完成", tone: "success" };
if (orders.every((item) => item.status === "cancelled")) return { label: "已取消", tone: "danger" };
if (orders.some((item) => item.status === "in_progress")) return { label: "进行中", tone: "info" };
return { label: "待处理", tone: "warning" };
}
function stageStatuses(status: PeripheralWorkOrder["status"]): [StageStatus, StageStatus, StageStatus] {
if (status === "completed") return ["done", "done", "done"]; if (status === "completed") return ["done", "done", "done"];
if (status === "in_progress") return ["done", "current", "pending"]; if (status === "in_progress") return ["done", "current", "pending"];
if (status === "open") return ["current", "pending", "pending"]; if (status === "open") return ["current", "pending", "pending"];
if (status === "cancelled") return ["skipped", "skipped", "skipped"]; if (status === "cancelled") return ["skipped", "skipped", "skipped"];
return ["pending", "pending", "pending"]; return ["pending", "pending", "pending"];
} }
function stageStatusLabel(status: StageStatus) { return ({ done: "已完成", current: "当前阶段", pending: "待开始", skipped: "已跳过" } satisfies Record<StageStatus, string>)[status]; } function workOrderStepTone(status: WorkOrderStepStatus): StatusTone { return ({ done: "success", current: "info", pending: "neutral", skipped: "neutral" } satisfies Record<WorkOrderStepStatus, StatusTone>)[status]; }
function workOrderStepStatusLabel(status: WorkOrderStepStatus) { return ({ done: "已完成", current: "当前步骤", pending: "待开始", skipped: "已跳过" } satisfies Record<WorkOrderStepStatus, string>)[status]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; } function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; } function qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; }
function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; } function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; }
@@ -18,14 +18,16 @@ import {
type PeripheralContext, type PeripheralContext,
type PeripheralObservations, type PeripheralObservations,
type PeripheralPointPage, type PeripheralPointPage,
type PeripheralScenario type PeripheralScenario,
type PeripheralWorkOrder
} from "./client"; } from "./client";
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果"; const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
export function PeripheralSamplePanel({ open, onClose, getAccessToken }: { export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
getAccessToken?: AccessTokenProvider; getAccessToken?: AccessTokenProvider;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) { }) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken)); const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current; const client = clientRef.current;
@@ -145,6 +147,7 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
canWrite={context.can_write} canWrite={context.can_write}
capabilities={context.managed_resources} capabilities={context.managed_resources}
onInventoryChange={setInventory} onInventoryChange={setInventory}
onAgentTrigger={onAgentTrigger}
/> />
<SyntheticDemoSection client={client} /> <SyntheticDemoSection client={client} />
<Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure> <Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { PeripheralWorkOrder } from "./client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
WATER_QUALITY_SCENARIO_CODE
} from "./work-order-agent-trigger";
const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrder => ({
work_order_id: "50000000-0000-4000-8000-000000000001",
project_id: "60000000-0000-4000-8000-000000000001",
revision: 1,
title: "居民反映自来水有异味",
summary: "演示小区多户居民反映晨间用水有异味。",
status: "open",
priority: "high",
assignee: null,
scheduled_at: null,
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "user",
updated_by: "user",
...overrides
});
describe("water-quality work-order trigger", () => {
it("derives a stable event identity from the persisted first revision", () => {
const first = toWaterQualityAgentTrigger(order());
const recovered = toWaterQualityAgentTrigger(order());
expect(first).toEqual(recovered);
expect(first?.trigger_id).toBe(
"peripheral.work_order.created:60000000-0000-4000-8000-000000000001:50000000-0000-4000-8000-000000000001:1"
);
expect(first?.payload.origin).toBe("synthetic");
});
it("does not trigger general, draft, revised, or unknown-scenario work orders", () => {
expect(toWaterQualityAgentTrigger(order({ work_order_type: "general" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ status: "draft" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ revision: 2 }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ scenario_code: null }))).toBeNull();
});
it("maps the event into a structured condition task linked to the Agent session", () => {
const condition = toWaterQualityConditionTask(
order(),
"agent-session-water-quality",
new Date("2026-09-22T02:01:00Z")
);
expect(condition).toMatchObject({
id: "peripheral-water-quality-50000000-0000-4000-8000-000000000001",
kind: "condition",
taskId: "water-quality-complaint",
sessionId: "agent-session-water-quality",
status: "running",
riskLevel: "attention",
scheduledAt: "2026-09-22T02:01:00.000Z"
});
expect(condition.evidence).toContain("投诉位置:演示小区");
});
});
@@ -0,0 +1,75 @@
import type { AgentEventTriggerContext } from "@/features/agent";
import type { ScheduledConditionRecord } from "../types";
import type { PeripheralWorkOrder } from "./client";
export const WATER_QUALITY_SCENARIO_CODE = "WQ-SECONDARY-SUPPLY-001" as const;
export function toWaterQualityAgentTrigger(
workOrder: PeripheralWorkOrder
): AgentEventTriggerContext | null {
if (
workOrder.revision !== 1 ||
workOrder.status !== "open" ||
workOrder.work_order_type !== "water_quality_complaint" ||
workOrder.scenario_code !== WATER_QUALITY_SCENARIO_CODE
) {
return null;
}
return {
schema_version: "agent-trigger@1",
kind: "event",
trigger_id: [
"peripheral.work_order.created",
workOrder.project_id,
workOrder.work_order_id,
workOrder.revision
].join(":"),
occurred_at: workOrder.created_at,
source: "peripheral_sample",
subject_type: "work_order",
subject_id: workOrder.work_order_id,
workflow: "water_quality_complaint",
payload: {
title: workOrder.title,
summary: workOrder.summary,
location: workOrder.location,
priority: workOrder.priority,
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic"
}
};
}
export function waterQualityTriggerPrompt(workOrder: PeripheralWorkOrder) {
return `外围样板新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
}
export function toWaterQualityConditionTask(
workOrder: PeripheralWorkOrder,
sessionId: string,
now = new Date()
): ScheduledConditionRecord {
return {
id: `peripheral-water-quality-${workOrder.work_order_id}`,
kind: "condition",
taskId: "water-quality-complaint",
dataSource: "mock",
sessionId,
title: "水质投诉调查",
summary: workOrder.summary,
status: "running",
riskLevel: workOrder.priority === "urgent" ? "critical" : "attention",
scheduledAt: now.toISOString(),
updatedAt: now.getTime(),
durationMinutes: 15,
detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`,
recommendation: "优先校验上游监测点数据质量;若上游指标正常,再转向二次供水设施和运维记录核查。",
evidence: [
`来源工单:${workOrder.work_order_id}`,
`投诉位置:${workOrder.location ?? "待确认"}`,
`投诉内容:${workOrder.title}`
],
modelName: "TJWater Agent 事件任务"
};
}
+2 -1
View File
@@ -65,7 +65,8 @@ export type ScheduledConditionTaskId =
| "scada-diagnosis" | "scada-diagnosis"
| "smart-dispatch" | "smart-dispatch"
| "pump-energy" | "pump-energy"
| "network-simulation"; | "network-simulation"
| "water-quality-complaint";
export type ScheduledConditionRecordTaskId = export type ScheduledConditionRecordTaskId =
| ScheduledConditionTaskId | ScheduledConditionTaskId
+23 -14
View File
@@ -10,6 +10,7 @@ describe("runtime frontend configuration", () => {
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/", TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_TIANDITU_TOKEN: "tianditu-token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test", TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
@@ -28,6 +29,7 @@ describe("runtime frontend configuration", () => {
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater", TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_TIANDITU_TOKEN: "tianditu-token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test", TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
@@ -47,6 +49,7 @@ describe("runtime frontend configuration", () => {
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({ expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
TJWATER_AUTH_MODE: "disabled", TJWATER_AUTH_MODE: "disabled",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_TIANDITU_TOKEN: "",
TJWATER_WORKBENCH_RUNTIME_MODE: "auto", TJWATER_WORKBENCH_RUNTIME_MODE: "auto",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "", TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo", TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo",
@@ -59,10 +62,12 @@ describe("runtime frontend configuration", () => {
}); });
it("accepts the explicit backend history mode", () => { it("accepts the explicit backend history mode", () => {
expect(parseRuntimeConfig({ expect(
TJWATER_AUTH_MODE: "disabled", parseRuntimeConfig({
TJWATER_WORKBENCH_RUNTIME_MODE: "history" TJWATER_AUTH_MODE: "disabled",
}).TJWATER_WORKBENCH_RUNTIME_MODE).toBe("history"); TJWATER_WORKBENCH_RUNTIME_MODE: "history"
}).TJWATER_WORKBENCH_RUNTIME_MODE
).toBe("history");
}); });
it("requires a Realm issuer when authentication is enabled", () => { it("requires a Realm issuer when authentication is enabled", () => {
@@ -93,15 +98,19 @@ describe("runtime frontend configuration", () => {
}); });
it("rejects an invalid or oversized peripheral demo window", () => { it("rejects an invalid or oversized peripheral demo window", () => {
expect(() => parseRuntimeConfig({ expect(() =>
TJWATER_AUTH_MODE: "disabled", parseRuntimeConfig({
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T16:00:00Z", TJWATER_AUTH_MODE: "disabled",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T08:00:00Z" TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T16:00:00Z",
})).toThrow(/演示窗口/); TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T08:00:00Z"
expect(() => parseRuntimeConfig({ })
TJWATER_AUTH_MODE: "disabled", ).toThrow(/演示窗口/);
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-01T00:00:00Z", expect(() =>
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T00:00:00Z" parseRuntimeConfig({
})).toThrow(/演示窗口/); TJWATER_AUTH_MODE: "disabled",
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-01T00:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T00:00:00Z"
})
).toThrow(/演示窗口/);
}); });
}); });
+15 -4
View File
@@ -43,12 +43,18 @@ const keycloakIssuer = z
if (!value) return; if (!value) return;
const parsed = browserHttpUrl.safeParse(value); const parsed = browserHttpUrl.safeParse(value);
if (!parsed.success) { if (!parsed.success) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" }); context.addIssue({
code: z.ZodIssueCode.custom,
message: "Keycloak issuer 必须是安全的 HTTP(S) 地址"
});
return; return;
} }
const url = new URL(value); const url = new URL(value);
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) { if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" }); context.addIssue({
code: z.ZodIssueCode.custom,
message: "Keycloak issuer 必须指向具体 Realm"
});
} }
}) })
.transform((value) => value.replace(/\/+$/, "")); .transform((value) => value.replace(/\/+$/, ""));
@@ -59,6 +65,7 @@ const runtimeConfigSchema = z
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer, TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"), TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""), TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
TJWATER_TIANDITU_TOKEN: z.string().default(""),
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"), TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"), TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"), TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
@@ -66,9 +73,13 @@ const runtimeConfigSchema = z
TJWATER_WORKBENCH_RUNTIME_MODE: z.enum(["auto", "demo", "history", "backend"]).default("auto"), TJWATER_WORKBENCH_RUNTIME_MODE: z.enum(["auto", "demo", "history", "backend"]).default("auto"),
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl, TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl,
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: z.enum(["demo", "recent"]).default("demo"), TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: z.enum(["demo", "recent"]).default("demo"),
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: z.string().datetime({ offset: true }) TJWATER_PERIPHERAL_SAMPLE_DEMO_START: z
.string()
.datetime({ offset: true })
.default("2026-09-17T00:00:00Z"), .default("2026-09-17T00:00:00Z"),
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: z.string().datetime({ offset: true }) TJWATER_PERIPHERAL_SAMPLE_DEMO_END: z
.string()
.datetime({ offset: true })
.default("2026-09-17T12:00:00Z"), .default("2026-09-17T12:00:00Z"),
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: z.coerce.number().int().min(1).max(168).default(8), TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: z.coerce.number().int().min(1).max(168).default(8),
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false), TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
// Uses generated local building data, while unrelated backend services are isolated.
test("3D adds opaque buildings without enabling duplicate GIS layers", async ({ page }) => {
test.skip(
process.env.TJWATER_CITY_SCENE_TEST !== "1",
"Requires the generated local city-model scene"
);
test.setTimeout(60000);
await mockWorkbenchContext(page, undefined, undefined, {}, [], {
tiandituToken: "city-scene-tianditu-token"
});
await page.route("https://t0.tianditu.gov.cn/**", (route) =>
route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE })
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const toggle = page
.getByRole("group", { name: "地图缩放", exact: true })
.locator("button[aria-pressed]");
await expect(toggle).toBeEnabled({ timeout: 15000 });
const businessLayers = await page.evaluate(() =>
window.__waterNetworkMap!.getStyle().layers.map((layer) => ({
id: layer.id,
visibility: window.__waterNetworkMap!.getLayoutProperty(layer.id, "visibility") ?? "visible"
}))
);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await page.evaluate(() =>
window.__waterNetworkMap?.jumpTo({ center: [121.464, 30.916], zoom: 16, pitch: 55 })
);
await expect
.poll(
() =>
page.evaluate(
() =>
window.__waterNetworkMap?.queryRenderedFeatures({ layers: ["fengxian-city-buildings"] })
.length ?? 0
),
{ timeout: 30000 }
)
.toBeGreaterThan(20);
const buildingPoint = await page.evaluate(() => {
const map = window.__waterNetworkMap!;
const canvas = map.getCanvas();
for (let y = 20; y < canvas.clientHeight; y += 20) {
for (let x = 20; x < canvas.clientWidth; x += 20) {
if (map.queryRenderedFeatures([x, y], { layers: ["fengxian-city-buildings"] }).length > 0) {
return { x, y };
}
}
}
return null;
});
expect(buildingPoint).not.toBeNull();
const mapCanvasBounds = await page.locator(".maplibregl-canvas").boundingBox();
expect(mapCanvasBounds).not.toBeNull();
await page.mouse.click(
mapCanvasBounds!.x + buildingPoint!.x,
mapCanvasBounds!.y + buildingPoint!.y
);
await expect(page.locator(".maplibregl-popup")).toHaveCount(0);
await expect(page.getByText("建筑参考模型", { exact: true })).toHaveCount(0);
const scene = await page.evaluate(() => ({
layers: window
.__waterNetworkMap!.getStyle()
.layers.filter((layer) => layer.id.startsWith("fengxian-city-"))
.map((layer) => layer.id),
sources: Object.keys(window.__waterNetworkMap!.getStyle().sources).filter((id) =>
id.startsWith("fengxian-city-")
),
opacity: window.__waterNetworkMap!.getPaintProperty(
"fengxian-city-buildings",
"fill-extrusion-opacity"
),
layerOrder: window.__waterNetworkMap!.getStyle().layers.map((layer) => layer.id)
}));
expect(scene.layers).toEqual(["fengxian-city-buildings"]);
expect(scene.sources).toEqual(["fengxian-city-buildings"]);
expect(scene.opacity).toBe(1);
const buildingIndex = scene.layerOrder.indexOf("fengxian-city-buildings");
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("tianditu-vector-annotation"));
expect(buildingIndex).toBeLessThan(
scene.layerOrder.findIndex((layerId) => layerId.startsWith("pipes-"))
);
for (const layer of businessLayers) {
expect(
await page.evaluate(
(id) => window.__waterNetworkMap!.getLayoutProperty(id, "visibility") ?? "visible",
layer.id
)
).toBe(layer.visibility);
}
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(90, { duration: 0 }));
await expect(page.locator("[data-map-compass]")).toHaveAttribute("data-bearing", "90.0");
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(0, { duration: 0 }));
await page.screenshot({ path: "../city-model-fengxian/reports/browser-nanqiao.png" });
await toggle.click();
await expect
.poll(() =>
page.evaluate(() =>
window.__waterNetworkMap?.getLayoutProperty("fengxian-city-buildings", "visibility")
)
)
.toBe("none");
await page.setViewportSize({ width: 390, height: 844 });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const box = await toggle.boundingBox();
expect(box?.width).toBe(40);
expect(box?.height).toBe(40);
await page.screenshot({ path: "../city-model-fengxian/reports/browser-mobile.png" });
});
+111
View File
@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("combined compass switches 2D and 3D with north reset first", async ({ page }) => {
await mockWorkbenchContext(page);
await page.route("**/city-model/manifest.json", (route) => route.fulfill({ status: 404 }));
for (const viewport of [
{ width: 1280, height: 800 },
{ width: 375, height: 812 }
]) {
await page.setViewportSize(viewport);
await page.goto("/", { waitUntil: "domcontentloaded" });
const zoomControls = page.getByRole("group", { name: "地图缩放", exact: true });
const modeButton = zoomControls.locator("button[aria-pressed]");
const compass = modeButton.locator("[data-map-compass]");
await expect(zoomControls).toBeVisible();
await expect(zoomControls.getByRole("button", { name: "放大", exact: true })).toBeVisible();
await expect(zoomControls.getByRole("button", { name: "缩小", exact: true })).toBeVisible();
await expect(
zoomControls.getByRole("button", { name: "切换到三维", exact: true })
).toBeVisible();
await expect(modeButton).toHaveAttribute("aria-pressed", "false");
await expect(compass).toHaveAttribute("data-map-mode", "2d");
await expect
.poll(() =>
page.evaluate(() => ({
dragRotate: window.__waterNetworkMap!.dragRotate.isEnabled(),
touchPitch: window.__waterNetworkMap!.touchPitch.isEnabled()
}))
)
.toEqual({ dragRotate: false, touchPitch: false });
await modeButton.click();
await expect
.poll(() => page.evaluate(() => window.__waterNetworkMap!.getPitch()))
.toBe(viewport.width < 640 ? 45 : 50);
await expect(modeButton).toHaveAttribute("aria-pressed", "true");
await expect(modeButton).toHaveAttribute("aria-label", "切换到二维");
await expect(compass).toHaveAttribute("data-map-mode", "3d");
await expect
.poll(() =>
page.evaluate(() => ({
dragRotate: window.__waterNetworkMap!.dragRotate.isEnabled(),
touchPitch: window.__waterNetworkMap!.touchPitch.isEnabled()
}))
)
.toEqual({ dragRotate: true, touchPitch: true });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ bearing: 60 }));
await expect(modeButton).toHaveAttribute("aria-label", "恢复北向");
await expect(compass).toHaveAttribute("data-bearing", "60.0");
await expect(compass.locator("svg")).toHaveAttribute(
"style",
/rotateX\([^)]*\) rotateZ\(-60deg\)/
);
await modeButton.click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getBearing())).toBe(0);
await expect(modeButton).toHaveAttribute("aria-label", "切换到二维");
expect(await page.evaluate(() => window.__waterNetworkMap!.getPitch())).toBeGreaterThan(0);
await modeButton.click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getPitch())).toBe(0);
await expect(modeButton).toHaveAttribute("aria-pressed", "false");
await expect(modeButton).toHaveAttribute("aria-label", "切换到三维");
await expect(compass).toHaveAttribute("data-map-mode", "2d");
await expect(
zoomControls.getByRole("button", { name: "缩放到全局", exact: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "调整视角", exact: true })).toHaveCount(0);
await expect(page.getByRole("region", { name: "地图视角" })).toHaveCount(0);
}
});
test("3D navigation overrides the browser context menu for right-drag rotation", async ({
page
}) => {
await mockWorkbenchContext(page);
await page.route("**/city-model/manifest.json", (route) => route.fulfill({ status: 404 }));
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const modeButton = page
.getByRole("group", { name: "地图缩放", exact: true })
.locator("button[aria-pressed]");
await modeButton.click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getPitch())).toBe(50);
const canvas = page.locator(".maplibregl-canvas");
const bounds = await canvas.boundingBox();
expect(bounds).not.toBeNull();
const startX = bounds!.x + bounds!.width / 2;
const startY = bounds!.y + bounds!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down({ button: "right" });
await page.mouse.move(startX + 80, startY, { steps: 4 });
await page.mouse.up({ button: "right" });
await expect
.poll(() => page.evaluate(() => Math.abs(window.__waterNetworkMap!.getBearing())))
.toBeGreaterThan(10);
expect(
await canvas.evaluate((element) =>
element.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
)
)
).toBe(false);
});
+176 -13
View File
@@ -217,12 +217,34 @@ test.describe("外围样板管理", () => {
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click(); await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible(); await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible();
const hierarchy = page.getByLabel("任务层级"); const workOrderList = page.getByLabel("工单列表");
await expect(hierarchy).toContainText("外围合成数据核查"); await expect(workOrderList).toContainText("合成通信核查");
await expect(hierarchy).toContainText("3 个子任务"); await expect(workOrderList).toContainText("合成压力偏差复核");
await expect(page.getByLabel("子任务阶段 合成通信核查")).toContainText(/派发.*当前阶段.*执行.*待开始.*复令.*待开始/); await expect(workOrderList).toContainText("合成零流量样本确认");
await expect(page.getByLabel("子任务阶段 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前阶段.*复令.*待开始/); await expect(workOrderList).toContainText("待处理");
await expect(page.getByLabel("子任务阶段 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/); await expect(workOrderList).toContainText("处理中");
await expect(workOrderList).toContainText("已完成");
await expect(page.getByLabel("工单步骤 合成通信核查")).toContainText(/派发.*当前步骤.*执行.*待开始.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前步骤.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/);
await expect(workOrderList).not.toContainText("外围合成数据核查");
await expect(workOrderList).not.toContainText("子任务");
await testInfo.attach("peripheral-work-orders-1280", {
body: await workOrderList.screenshot(),
contentType: "image/png"
});
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const firstOrderSteps = page.getByLabel("工单步骤 合成通信核查");
await firstOrderSteps.scrollIntoViewIfNeeded();
await expect(firstOrderSteps).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-orders-${width}`, {
body: await page.screenshot(),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const scenarioSelect = page.getByRole("combobox", { name: "场景" }); const scenarioSelect = page.getByRole("combobox", { name: "场景" });
await scenarioSelect.focus(); await scenarioSelect.focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
@@ -375,7 +397,7 @@ test.describe("外围样板管理", () => {
expect(managedRequests).toEqual([]); expect(managedRequests).toEqual([]);
}); });
test("创建、修改并软删除测试工单和测试数据", async ({ page }) => { test("创建、修改并软删除测试工单和测试数据", async ({ page }, testInfo) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
let workOrders: Record<string, unknown>[] = []; let workOrders: Record<string, unknown>[] = [];
let testData: Record<string, unknown>[] = []; let testData: Record<string, unknown>[] = [];
@@ -418,19 +440,33 @@ test.describe("外围样板管理", () => {
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click(); await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" }); const panel = page.getByRole("complementary", { name: "外围样板管理" });
await expect(panel.getByText("外围任务编排", { exact: true })).toBeVisible(); await expect(panel.getByText("外围工单与测试数据", { exact: true })).toBeVisible();
await expect(panel.getByLabel("任务层级")).toContainText("外围合成数据核查"); await expect(panel.getByLabel("工单列表")).toContainText("暂无测试工单");
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden(); await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
await panel.getByRole("button", { name: "新增子任务" }).click(); await panel.getByRole("button", { name: "新增工单" }).click();
const workOrderEditor = panel.getByRole("textbox", { name: "标题" }).locator("xpath=../..");
await expect(workOrderEditor).toHaveCSS("background-color", "rgb(255, 255, 255)");
await testInfo.attach("work-order-editor-1280", {
body: await workOrderEditor.screenshot(),
contentType: "image/png"
});
await page.setViewportSize({ width: 375, height: 720 });
await workOrderEditor.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("work-order-editor-375", {
body: await page.screenshot(),
contentType: "image/png"
});
await page.setViewportSize({ width: 1280, height: 720 });
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单"); await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单");
await panel.getByRole("textbox", { name: "说明" }).fill("外围 CRUD 联调"); await panel.getByRole("textbox", { name: "说明" }).fill("外围 CRUD 联调");
await panel.getByRole("button", { name: "创建子任务" }).click(); await panel.getByRole("button", { name: "创建工单" }).click();
await expect(panel.getByText("演示 测试工单", { exact: true })).toBeVisible(); await expect(panel.getByText("演示 测试工单", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑子任务 演示 测试工单" }).click(); await panel.getByRole("button", { name: "编辑工单 演示 测试工单" }).click();
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单(已改)"); await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单(已改)");
await panel.getByRole("button", { name: "保存修改" }).click(); await panel.getByRole("button", { name: "保存修改" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeVisible(); await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "删除子任务 演示 测试工单(已改)" }).click(); await panel.getByRole("button", { name: "删除工单 演示 测试工单(已改)" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeHidden(); await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeHidden();
await panel.getByRole("group", { name: "外围样板资源类型" }).getByRole("button", { name: /测试数据/ }).click(); await panel.getByRole("group", { name: "外围样板资源类型" }).getByRole("button", { name: /测试数据/ }).click();
@@ -447,6 +483,133 @@ test.describe("外围样板管理", () => {
await expect(panel.getByText("演示 压力样本", { exact: true })).toBeHidden(); await expect(panel.getByText("演示 压力样本", { exact: true })).toBeHidden();
}); });
test("创建水质投诉工单后进入结构化工况任务并由用户打开 Agent", async ({ page }, testInfo) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const workOrderId = "50000000-0000-4000-8000-000000000099";
const agentRequests: Array<Record<string, unknown>> = [];
let workOrders: Record<string, unknown>[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/api/v1/agent/chat/") &&
request.postData()
) {
agentRequests.push(request.postDataJSON() as Record<string, unknown>);
}
});
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic",
project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001",
can_write: true, scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: { items: [], next_cursor: null, origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: { items: [], availability: "not_configured", reason: "未配置", origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
const item = {
...body,
work_order_id: workOrderId,
project_id: projectId,
revision: 1,
origin: "synthetic",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "tester",
updated_by: "tester"
};
delete item.request_id;
workOrders = [item];
return route.fulfill({ status: 201, json: item });
}
if (path.endsWith("/test-data")) return route.fulfill({ json: { items: [], next_cursor: null } });
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await panel.getByRole("button", { name: "水质投诉演示" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeHidden();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible();
await expect(conditionPanel.getByText("演示执行流程 · 水质投诉调查流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("接收投诉工单", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("校验上游水质监测", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("研判供水链路", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("核查二次供水", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("汇总结论与处置", { exact: true }).last()).toBeVisible();
await testInfo.attach("water-quality-condition-task-1280", {
body: await conditionPanel.screenshot(),
contentType: "image/png"
});
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const responsiveConditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
if (!await responsiveConditionPanel.isVisible()) {
await page.getByRole("button", { name: "打开工况任务", exact: true }).click();
}
await expect(responsiveConditionPanel).toBeVisible();
const expandResponsiveTask = responsiveConditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandResponsiveTask.isVisible()) {
await expandResponsiveTask.click();
}
await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible();
const responsiveWorkflowStep = responsiveConditionPanel
.getByText("校验上游水质监测", { exact: true })
.last();
await responsiveWorkflowStep.scrollIntoViewIfNeeded();
await expect(responsiveWorkflowStep).toBeVisible();
await page.waitForTimeout(350);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`water-quality-condition-task-${width}`, {
body: await page.screenshot(),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const restoredConditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
if (!await restoredConditionPanel.isVisible()) {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
}
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "与 Agent 协同" });
if (!await agentCollaborationButton.isVisible()) {
await restoredConditionPanel.getByRole("button", { name: "展开工况任务" }).click();
}
await agentCollaborationButton.click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
expect(workOrders[0]).toMatchObject({
work_order_type: "water_quality_complaint",
scenario_code: "WQ-SECONDARY-SUPPLY-001",
status: "open"
});
const triggerRequests = agentRequests.filter((body) => "trigger_context" in body);
expect(triggerRequests).toHaveLength(2);
expect(new Set(triggerRequests.map((body) =>
(body.trigger_context as { trigger_id: string }).trigger_id
))).toEqual(new Set([
`peripheral.work_order.created:${projectId}:${workOrderId}:1`
]));
});
test("在 375px 视口内完整呈现面板", async ({ page }) => { test("在 375px 视口内完整呈现面板", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 720 }); await page.setViewportSize({ width: 375, height: 720 });
await page.goto("/"); await page.goto("/");
+42
View File
@@ -17,9 +17,51 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
description: "Playwright 浏览器测试", description: "Playwright 浏览器测试",
icon: "bolt" as const icon: "bolt" as const
}; };
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
await page.route("**/api/v1/agent/chat/**", async (route) => { await page.route("**/api/v1/agent/chat/**", async (route) => {
const pathname = new URL(route.request().url()).pathname; const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith("/session") && route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
trigger_context?: { trigger_id?: string };
};
const triggerId = body.trigger_context?.trigger_id;
const existing = triggerId ? triggeredSessions.get(triggerId) : undefined;
const sessionId = existing?.sessionId ?? `browser-session-${triggeredSessions.size + 1}`;
if (triggerId && !existing) triggeredSessions.set(triggerId, { sessionId, triggerId });
await route.fulfill({
status: existing ? 200 : 201,
json: {
session_id: sessionId,
title: "水质投诉事件调查",
status: "active",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created: !existing,
event_duplicate: Boolean(existing),
trigger_id: triggerId
}
});
return;
}
const sessionMatch = pathname.match(/\/session\/([^/]+)$/);
if (sessionMatch && route.request().method() === "GET") {
await route.fulfill({
json: {
id: sessionMatch[1],
session_id: sessionMatch[1],
title: "水质投诉事件调查",
status: "active",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
messages: [],
is_streaming: false
}
});
return;
}
if (pathname.endsWith("/stream")) { if (pathname.endsWith("/stream")) {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
@@ -8,7 +8,10 @@ export async function mockWorkbenchContext(
projectPresentation?: { metadata: unknown; accessContext: unknown }, projectPresentation?: { metadata: unknown; accessContext: unknown },
analysisRuns: Record<string, unknown> = {}, analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = [], analysisHistory: unknown[] = [],
options: { runtimeMode?: "auto" | "demo" | "history" | "backend" } = {} options: {
runtimeMode?: "auto" | "demo" | "history" | "backend";
tiandituToken?: string;
} = {}
) { ) {
await mockScadaApi( await mockScadaApi(
page, page,
@@ -29,6 +32,7 @@ export async function mockWorkbenchContext(
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_TIANDITU_TOKEN: options.tiandituToken ?? "",
TJWATER_WORKBENCH_RUNTIME_MODE: options.runtimeMode ?? "auto", TJWATER_WORKBENCH_RUNTIME_MODE: options.runtimeMode ?? "auto",
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
})};` })};`
+59 -4
View File
@@ -44,6 +44,35 @@ test.describe("Main Frame workbench", () => {
}); });
}); });
test("desktop selector switches Tianditu vector and imagery layer pairs", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await openWorkbench(page);
await selectBasemap(page, "天地图矢量");
await expectBaseLayerVisibility(page, {
"tianditu-vector": "visible",
"tianditu-vector-annotation": "visible",
"tianditu-image": "none",
"tianditu-image-annotation": "none"
});
await selectBasemap(page, "天地图影像");
await expectBaseLayerVisibility(page, {
"tianditu-vector": "none",
"tianditu-vector-annotation": "none",
"tianditu-image": "visible",
"tianditu-image-annotation": "visible"
});
});
test("mobile layer panel keeps Tianditu choices usable", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await openWorkbench(page);
await selectBasemap(page, "天地图矢量");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "light");
});
test("wide desktop expands the map document when Agent is collapsed", async ({ page }) => { test("wide desktop expands the map document when Agent is collapsed", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 }); await page.setViewportSize({ width: 1920, height: 1080 });
await openWorkbench(page); await openWorkbench(page);
@@ -58,7 +87,9 @@ test.describe("Main Frame workbench", () => {
await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible(); await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0); await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1920); await expect
.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0))
.toBe(1920);
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width); expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", { await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled", animations: "disabled",
@@ -123,6 +154,7 @@ async function prepareDeterministicWorkbench(page: Page) {
body: `globalThis.__TJWATER_CONFIG__ = { body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled", TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token", TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
TJWATER_TIANDITU_TOKEN: "visual-test-tianditu-token",
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver", TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
@@ -135,6 +167,9 @@ async function prepareDeterministicWorkbench(page: Page) {
await page.route("https://api.mapbox.com/**", async (route) => { await page.route("https://api.mapbox.com/**", async (route) => {
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE }); await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
}); });
await page.route("https://t0.tianditu.gov.cn/**", async (route) => {
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
});
await page.route("https://visual-map.invalid/**", async (route) => { await page.route("https://visual-map.invalid/**", async (route) => {
await route.fulfill({ await route.fulfill({
contentType: "application/vnd.mapbox-vector-tile", contentType: "application/vnd.mapbox-vector-tile",
@@ -182,19 +217,39 @@ async function openWorkbench(page: Page) {
await page.evaluate(async () => { await page.evaluate(async () => {
await document.fonts.ready; await document.fonts.ready;
}); });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))); await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
);
} }
async function selectBasemap(page: Page, label: "浅色" | "影像") { type BasemapLabel = "浅色" | "影像" | "天地图矢量" | "天地图影像";
async function selectBasemap(page: Page, label: BasemapLabel) {
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.getByRole("button", { name: label, exact: true }).click(); await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await expect(page.locator("main")).toHaveAttribute( await expect(page.locator("main")).toHaveAttribute(
"data-basemap-tone", "data-basemap-tone",
label === "影像" ? "satellite" : "light" ["影像", "天地图影像"].includes(label) ? "satellite" : "light"
); );
} }
async function expectBaseLayerVisibility(page: Page, expected: Record<string, "visible" | "none">) {
await expect
.poll(async () =>
page.evaluate((layerIds) => {
const map = window.__waterNetworkMap;
return Object.fromEntries(
layerIds.map((layerId) => [
layerId,
map?.getLayoutProperty(layerId, "visibility") ?? "visible"
])
);
}, Object.keys(expected))
)
.toEqual(expected);
}
async function openDesktopAgent(page: Page) { async function openDesktopAgent(page: Page) {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible(); await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
+4 -6
View File
@@ -12,21 +12,19 @@ function renderRuntimeConfig(values: Record<string, string>) {
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "", TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "", TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
TJWATER_TIANDITU_TOKEN: values.TJWATER_TIANDITU_TOKEN || "",
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver", TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater_next", TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater_next",
TJWATER_SERVER_API_BASE_URL: values.TJWATER_SERVER_API_BASE_URL || "http://127.0.0.1:8000", TJWATER_SERVER_API_BASE_URL: values.TJWATER_SERVER_API_BASE_URL || "http://127.0.0.1:8000",
TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787", TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787",
TJWATER_WORKBENCH_RUNTIME_MODE: values.TJWATER_WORKBENCH_RUNTIME_MODE || "auto", TJWATER_WORKBENCH_RUNTIME_MODE: values.TJWATER_WORKBENCH_RUNTIME_MODE || "auto",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: values.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL || "",
values.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL || "", TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: values.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE || "demo",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:
values.TJWATER_PERIPHERAL_SAMPLE_TIME_MODE || "demo",
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: TJWATER_PERIPHERAL_SAMPLE_DEMO_START:
values.TJWATER_PERIPHERAL_SAMPLE_DEMO_START || "2026-09-17T00:00:00Z", values.TJWATER_PERIPHERAL_SAMPLE_DEMO_START || "2026-09-17T00:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: TJWATER_PERIPHERAL_SAMPLE_DEMO_END:
values.TJWATER_PERIPHERAL_SAMPLE_DEMO_END || "2026-09-17T12:00:00Z", values.TJWATER_PERIPHERAL_SAMPLE_DEMO_END || "2026-09-17T12:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: values.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS || "8",
values.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS || "8",
TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false", TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false" TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
}; };