feat: extend supply workbench demo and map navigation
This commit is contained in:
@@ -2,6 +2,7 @@ TJWATER_AUTH_MODE=required
|
||||
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||
TJWATER_TIANDITU_TOKEN=
|
||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
TJWATER_GEOSERVER_WORKSPACE=tjwater_next
|
||||
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
@@ -5,6 +5,7 @@ dist-server
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
/public/city-model
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -32,26 +32,27 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
|
||||
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
|
||||
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
||||
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 |
|
||||
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
||||
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||
| `TJWATER_TIANDITU_TOKEN` | 空 | 天地图矢量、影像及中文注记访问令牌 |
|
||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
|
||||
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
|
||||
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
||||
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
|
||||
| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 |
|
||||
| `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 中已有登录会话时会直接完成单点登录。
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ runtime_config=$(jq -cn \
|
||||
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||
--arg tiandituToken "${TJWATER_TIANDITU_TOKEN:-}" \
|
||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
|
||||
--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_CLIENT_ID: $keycloakClientId,
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||
TJWATER_TIANDITU_TOKEN: $tiandituToken,
|
||||
TJWATER_MAP_URL: $mapUrl,
|
||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.14",
|
||||
"pmtiles": "4.3.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"shiki": "^4.2.0",
|
||||
|
||||
Generated
+16
@@ -98,6 +98,9 @@ importers:
|
||||
nanoid:
|
||||
specifier: ^5.1.14
|
||||
version: 5.1.16
|
||||
pmtiles:
|
||||
specifier: 4.3.0
|
||||
version: 4.3.0
|
||||
react:
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.7
|
||||
@@ -2456,6 +2459,7 @@ packages:
|
||||
eslint@9.39.5:
|
||||
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
||||
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
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -2531,6 +2535,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -3354,6 +3361,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
pmtiles@4.3.0:
|
||||
resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -6271,6 +6281,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -7373,6 +7385,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pmtiles@4.3.0:
|
||||
dependencies:
|
||||
fflate: 0.8.3
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
|
||||
@@ -2,6 +2,8 @@ globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "",
|
||||
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_WORKBENCH_RUNTIME_MODE: "auto"
|
||||
};
|
||||
|
||||
@@ -3,6 +3,25 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
|
||||
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 AgentModelOption = {
|
||||
@@ -29,6 +48,9 @@ export type AgentChatSession = {
|
||||
updated_at?: number | string;
|
||||
is_title_manually_edited?: boolean;
|
||||
parent_session_id?: string;
|
||||
created?: boolean;
|
||||
event_duplicate?: boolean;
|
||||
trigger_id?: string;
|
||||
};
|
||||
|
||||
export type AgentChatSessionSummary = {
|
||||
@@ -80,7 +102,7 @@ export type AgentSessionStreamEvent =
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
createSession: () => Promise<AgentChatSession>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||
listSessions: () => Promise<AgentChatSessionSummary[]>;
|
||||
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||
streamSession: (
|
||||
@@ -154,11 +176,11 @@ export function createAgentApiClient(
|
||||
};
|
||||
|
||||
return {
|
||||
async createSession() {
|
||||
async createSession(options) {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify({ trigger_context: options?.triggerContext })
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
AgentApiClient,
|
||||
AgentApiClientOptions,
|
||||
AgentChatSessionSummary,
|
||||
AgentEventTriggerContext,
|
||||
AgentLoadedChatSession,
|
||||
AgentSessionStreamEvent
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
|
||||
import { type RefObject, useCallback } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import { Home, Minus, Navigation2, Plus, type LucideIcon } from "lucide-react";
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
CAMERA_MODE_ANGLE_TOLERANCE,
|
||||
getCombinedCameraAction,
|
||||
setThreeDimensionalNavigation
|
||||
} from "./camera-actions";
|
||||
import {
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
@@ -19,10 +19,60 @@ type MapZoomProps = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
onHome: () => void;
|
||||
reducedMotion?: boolean;
|
||||
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(() => {
|
||||
mapRef.current?.zoomIn({ duration: 260 });
|
||||
}, [mapRef]);
|
||||
@@ -36,10 +86,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
<div
|
||||
role="group"
|
||||
aria-label="地图缩放"
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-12 flex-col gap-2",
|
||||
className
|
||||
)}
|
||||
className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -48,34 +95,33 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<ControlButton
|
||||
icon={Plus}
|
||||
label="放大"
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomIn}
|
||||
/>
|
||||
<ControlButton icon={Plus} label="放大" disabled={!mapReady} onClick={handleZoomIn} />
|
||||
<Divider />
|
||||
<ControlButton icon={Minus} label="缩小" disabled={!mapReady} onClick={handleZoomOut} />
|
||||
<Divider />
|
||||
<ControlButton
|
||||
icon={Minus}
|
||||
label="缩小"
|
||||
content={
|
||||
<CompassModeIndicator
|
||||
bearing={orientation.bearing}
|
||||
pitch={orientation.pitch}
|
||||
mode={is3d ? "3D" : "2D"}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
cameraAction === "reset-north"
|
||||
? "恢复北向"
|
||||
: cameraAction === "exit-3d"
|
||||
? "切换到二维"
|
||||
: "切换到三维"
|
||||
}
|
||||
pressed={is3d}
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomOut}
|
||||
onClick={handleCameraMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<ControlButton
|
||||
icon={Home}
|
||||
label="缩放到全局"
|
||||
disabled={!mapReady}
|
||||
onClick={onHome}
|
||||
/>
|
||||
<div className={cn("p-1", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<ControlButton icon={Home} label="缩放到全局" disabled={!mapReady} onClick={onHome} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -83,19 +129,29 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
}
|
||||
|
||||
type ControlButtonProps = {
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon;
|
||||
content?: ReactNode;
|
||||
pressed?: boolean;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
|
||||
function ControlButton({
|
||||
icon: Icon,
|
||||
content,
|
||||
pressed,
|
||||
label,
|
||||
disabled,
|
||||
onClick
|
||||
}: ControlButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
@@ -103,11 +159,11 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"bg-transparent text-slate-700",
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
disabled &&
|
||||
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
|
||||
pressed && "bg-blue-50 text-blue-700",
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<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 (
|
||||
<div
|
||||
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
|
||||
<span
|
||||
className="relative grid h-7 w-7 place-items-center"
|
||||
style={{ perspective: "70px" }}
|
||||
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.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 ? (
|
||||
<PersistedRunningConditionBody condition={condition} />
|
||||
) : (
|
||||
<RunningConditionBody condition={condition} />
|
||||
<RunningConditionBody
|
||||
condition={condition}
|
||||
onContinueConversation={onContinueConversation}
|
||||
/>
|
||||
)
|
||||
) : backendCondition ? (
|
||||
<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 conversationAvailable = condition.taskId === "water-quality-complaint";
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -435,11 +445,12 @@ function RunningConditionBody({ condition }: { condition: ScheduledConditionReco
|
||||
<RunningEvidencePreview items={runningEvidence} />
|
||||
<DecisionActionStrip
|
||||
icon={Bot}
|
||||
title="阶段判断"
|
||||
title={conversationAvailable ? "Agent 协同" : "阶段判断"}
|
||||
value={condition.recommendation ?? "等待本轮工况完成后再确认是否生成处置建议。"}
|
||||
actionLabel="等待完成"
|
||||
actionDisabled
|
||||
actionTitle="任务执行中,完成后可继续对话"
|
||||
actionLabel={conversationAvailable ? "与 Agent 协同" : "等待完成"}
|
||||
actionDisabled={!conversationAvailable}
|
||||
actionTitle={conversationAvailable ? "打开这条任务关联的 Agent 会话" : "任务执行中,完成后可继续对话"}
|
||||
onAction={conversationAvailable ? onContinueConversation : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -142,6 +142,37 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
|
||||
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 AgentChatMessage,
|
||||
type AgentConnectionStatus,
|
||||
type AgentEventTriggerContext,
|
||||
type AgentModelOption,
|
||||
type AgentPermissionReply,
|
||||
type AgentPermissionRequest,
|
||||
@@ -694,6 +695,70 @@ export function useWorkbenchAgent({
|
||||
[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(() => {
|
||||
chat.stop();
|
||||
stopSessionStreamSubscription();
|
||||
@@ -845,6 +910,7 @@ export function useWorkbenchAgent({
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
resolveRenderRef,
|
||||
triggerEvent,
|
||||
submitPrompt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
scadaInteractionLayers
|
||||
} from "../map/scada";
|
||||
import { registerSupplyAssetImages } from "../map/supply-icons";
|
||||
import { installCityScene } from "../map/city-scene-control";
|
||||
import { useMapInteractions } from "./use-map-interactions";
|
||||
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
|
||||
|
||||
@@ -79,7 +80,10 @@ type UseWorkbenchMapResult = {
|
||||
sourceStatuses: WorkbenchSourceStatus[];
|
||||
availableSourceIds: WaterNetworkSourceId[];
|
||||
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;
|
||||
refreshGeoServerLayers: () => boolean;
|
||||
fitNetworkBounds: () => void;
|
||||
@@ -96,6 +100,7 @@ export type WorkbenchSourceStatus = {
|
||||
|
||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
"mapbox-base": "Mapbox 底图",
|
||||
"tianditu-base": "天地图底图",
|
||||
"geoserver-mvt": "GeoServer MVT",
|
||||
"geoserver-catalog": "GeoServer 图层目录",
|
||||
"scada-api": "SCADA API",
|
||||
@@ -106,6 +111,10 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "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(
|
||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
),
|
||||
@@ -197,9 +206,11 @@ export function useWorkbenchMap({
|
||||
? current
|
||||
: availability.availableSourceIds
|
||||
);
|
||||
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
|
||||
? current
|
||||
: availability.featureCounts);
|
||||
setSourceFeatureCounts((current) =>
|
||||
areFeatureCountsEqual(current, availability.featureCounts)
|
||||
? current
|
||||
: availability.featureCounts
|
||||
);
|
||||
|
||||
if (availability.failedSourceIds.length > 0) {
|
||||
updateSourceStatus(
|
||||
@@ -251,12 +262,13 @@ export function useWorkbenchMap({
|
||||
setMapReady(false);
|
||||
setMapError(null);
|
||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||
const tiandituToken = env.TJWATER_TIANDITU_TOKEN || undefined;
|
||||
const scadaAbortController = new AbortController();
|
||||
let scadaRefreshTimeoutId: number | undefined;
|
||||
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: createBaseStyle(mapboxToken),
|
||||
style: createBaseStyle(mapboxToken, tiandituToken),
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
@@ -265,9 +277,11 @@ export function useWorkbenchMap({
|
||||
});
|
||||
|
||||
window.__waterNetworkMap = map;
|
||||
let removeCityScene: (() => void) | undefined;
|
||||
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
removeCityScene = installCityScene(map);
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
scadaCollectionRef.current
|
||||
);
|
||||
@@ -279,11 +293,7 @@ export function useWorkbenchMap({
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
|
||||
syncSimulationSourceData(
|
||||
map,
|
||||
simulationOverlayRef.current,
|
||||
allowDemoSimulationRef.current
|
||||
);
|
||||
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
const activeScadaBusinessLayers =
|
||||
@@ -340,10 +350,7 @@ export function useWorkbenchMap({
|
||||
});
|
||||
|
||||
const refreshScada = async () => {
|
||||
const snapshotPromise = fetchScadaSnapshot(
|
||||
getAccessToken,
|
||||
scadaAbortController.signal
|
||||
);
|
||||
const snapshotPromise = fetchScadaSnapshot(getAccessToken, scadaAbortController.signal);
|
||||
scadaLoadPromiseRef.current = snapshotPromise
|
||||
.then((snapshot) => snapshot.collection)
|
||||
.catch(() => scadaCollectionRef.current);
|
||||
@@ -428,6 +435,7 @@ export function useWorkbenchMap({
|
||||
if (scadaRefreshTimeoutId !== undefined) {
|
||||
window.clearTimeout(scadaRefreshTimeoutId);
|
||||
}
|
||||
removeCityScene?.();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
@@ -500,8 +508,10 @@ function areFeatureCountsEqual(
|
||||
) {
|
||||
const currentEntries = Object.entries(current);
|
||||
const nextEntries = Object.entries(next);
|
||||
return currentEntries.length === nextEntries.length
|
||||
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
|
||||
return (
|
||||
currentEntries.length === nextEntries.length &&
|
||||
nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count)
|
||||
);
|
||||
}
|
||||
|
||||
function networkViewKey(view: NetworkViewParams) {
|
||||
@@ -549,8 +559,9 @@ function syncDataDependentGeoServerLayers(
|
||||
|
||||
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||
if (availableSourceIdSet.has(sourceId)) return;
|
||||
map.getStyle().layers
|
||||
.filter((layer) => "source" in layer && layer.source === sourceId)
|
||||
map
|
||||
.getStyle()
|
||||
.layers.filter((layer) => "source" in layer && layer.source === sourceId)
|
||||
.reverse()
|
||||
.forEach((layer) => map.removeLayer(layer.id));
|
||||
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||
@@ -711,11 +722,15 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
|
||||
return "mapbox-base";
|
||||
}
|
||||
|
||||
if (message.includes("tianditu.gov.cn") || message.includes("tianditu")) {
|
||||
return "tianditu-base";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
if (["mapbox-base", "tianditu-base"].includes(sourceGroupId)) {
|
||||
return status === "online"
|
||||
? "底图瓦片已恢复正常。"
|
||||
: "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
|
||||
@@ -13,6 +13,8 @@ describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
["mapbox-light", "light"],
|
||||
["mapbox-satellite", "satellite"],
|
||||
["tianditu-vector", "light"],
|
||||
["tianditu-image", "satellite"],
|
||||
["none", "none"],
|
||||
["unknown", "none"]
|
||||
])("maps %s to %s", (baseLayerId, expectedTone) => {
|
||||
|
||||
@@ -37,11 +37,8 @@ export const WORKBENCH_LAYOUT = {
|
||||
} as const;
|
||||
|
||||
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
|
||||
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
|
||||
WORKBENCH_LAYOUT.mobileOverlay;
|
||||
const dockBottom = timelineVisible
|
||||
? edgeInset + timelineSummaryHeight + gap
|
||||
: edgeInset;
|
||||
const { edgeInset, gap, dockHeight, timelineSummaryHeight } = WORKBENCH_LAYOUT.mobileOverlay;
|
||||
const dockBottom = timelineVisible ? edgeInset + timelineSummaryHeight + gap : edgeInset;
|
||||
|
||||
return {
|
||||
timelineBottom: edgeInset,
|
||||
@@ -118,11 +115,11 @@ export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchView
|
||||
}
|
||||
|
||||
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
|
||||
if (activeBaseLayerId === "mapbox-light") {
|
||||
if (["mapbox-light", "tianditu-vector"].includes(activeBaseLayerId)) {
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (activeBaseLayerId === "mapbox-satellite") {
|
||||
if (["mapbox-satellite", "tianditu-image"].includes(activeBaseLayerId)) {
|
||||
return "satellite";
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { AnalysisResultsPanel } from "./components/analysis-results-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 { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
@@ -56,9 +62,7 @@ import {
|
||||
type ToolbarToolId
|
||||
} from "./components/toolbar-panel";
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
WorkbenchMainFrame
|
||||
} from "./workspace/workbench-main-frame";
|
||||
import { WorkbenchMainFrame } from "./workspace/workbench-main-frame";
|
||||
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||
import {
|
||||
createInitialWorkspaceContext,
|
||||
@@ -71,10 +75,7 @@ import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
|
||||
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
|
||||
import {
|
||||
formatHeaderDataTime,
|
||||
useWorkbenchRuntimeData
|
||||
} from "./hooks/use-workbench-runtime-data";
|
||||
import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import {
|
||||
useWorkbenchMeasurement,
|
||||
@@ -121,6 +122,8 @@ export function MapWorkbenchPage({
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
}) {
|
||||
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 projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -138,7 +141,7 @@ export function MapWorkbenchPage({
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
const [activeBaseLayerId, setActiveBaseLayerId] = useState(
|
||||
hasMapboxToken ? "mapbox-light" : "none"
|
||||
hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none"
|
||||
);
|
||||
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
|
||||
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
|
||||
@@ -149,7 +152,7 @@ export function MapWorkbenchPage({
|
||||
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
||||
const {
|
||||
headerDataTime,
|
||||
scheduledConditions,
|
||||
scheduledConditions: runtimeScheduledConditions,
|
||||
scheduledConditionsLoading,
|
||||
workbenchAlerts,
|
||||
runtimeDataSource,
|
||||
@@ -161,13 +164,22 @@ export function MapWorkbenchPage({
|
||||
operationalDate,
|
||||
operationalCursorTime
|
||||
} = useWorkbenchRuntimeData(getAccessToken);
|
||||
const [eventConditions, setEventConditions] = useState<ScheduledConditionRecord[]>([]);
|
||||
const scheduledConditions = useMemo(
|
||||
() => [...eventConditions, ...runtimeScheduledConditions],
|
||||
[eventConditions, runtimeScheduledConditions]
|
||||
);
|
||||
const baseLayerOptions = useMemo(
|
||||
() =>
|
||||
BASE_LAYER_OPTIONS.map((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) => {
|
||||
@@ -215,19 +227,18 @@ export function MapWorkbenchPage({
|
||||
expandAgentPanel,
|
||||
onClearActiveTool: clearActiveTool
|
||||
});
|
||||
const currentAgentWidth = leftPanelOpen
|
||||
? agentPanelWidth
|
||||
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
|
||||
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
|
||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
||||
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
|
||||
const allowDemoSimulation = artifactWorkspace.activeArtifact === null
|
||||
|| artifactWorkspace.activeArtifact.origin === "demo";
|
||||
const allowDemoSimulation =
|
||||
artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo";
|
||||
const latestAnalysisAvailable = Boolean(
|
||||
latestAnalysisArtifact?.blocks.some((block) =>
|
||||
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
|
||||
latestAnalysisArtifact?.blocks.some(
|
||||
(block) =>
|
||||
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
|
||||
)
|
||||
);
|
||||
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
|
||||
@@ -278,15 +289,62 @@ export function MapWorkbenchPage({
|
||||
|
||||
useEffect(() => {
|
||||
setImpactVisible(activeArtifactHasSpatialView);
|
||||
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView
|
||||
? current
|
||||
: { ...current, simulation: activeArtifactHasSpatialView });
|
||||
setLayerVisibility((current) =>
|
||||
current.simulation === activeArtifactHasSpatialView
|
||||
? current
|
||||
: { ...current, simulation: activeArtifactHasSpatialView }
|
||||
);
|
||||
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
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() {
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
@@ -325,10 +383,10 @@ export function MapWorkbenchPage({
|
||||
networkView: projectPresentation.networkView,
|
||||
getAccessToken
|
||||
});
|
||||
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE)
|
||||
&& scadaObservedAt
|
||||
? formatHeaderDataTime(new Date(scadaObservedAt), false)
|
||||
: headerDataTime;
|
||||
const displayedDataTime =
|
||||
!["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE) && scadaObservedAt
|
||||
? formatHeaderDataTime(new Date(scadaObservedAt), false)
|
||||
: headerDataTime;
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
@@ -378,12 +436,12 @@ export function MapWorkbenchPage({
|
||||
});
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
!artifactActive
|
||||
&& mobileSheet === null
|
||||
&& !conditionFeedExpanded
|
||||
&& detailFeature === null
|
||||
&& taskTickerVisible
|
||||
&& taskTickerAvailable;
|
||||
!artifactActive &&
|
||||
mobileSheet === null &&
|
||||
!conditionFeedExpanded &&
|
||||
detailFeature === null &&
|
||||
taskTickerVisible &&
|
||||
taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -436,41 +494,44 @@ export function MapWorkbenchPage({
|
||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
||||
}, []);
|
||||
|
||||
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-operational-event",
|
||||
eventId: event.id,
|
||||
cursorTime: event.cursorTime
|
||||
});
|
||||
|
||||
const mapTarget = event.mapTargets[0];
|
||||
if (mapTarget) {
|
||||
const handleSelectOperationalEvent = useCallback(
|
||||
(event: OperationalEvent) => {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-spatial-feature",
|
||||
featureId: mapTarget.featureId,
|
||||
sourceId: mapTarget.sourceId
|
||||
type: "select-operational-event",
|
||||
eventId: event.id,
|
||||
cursorTime: event.cursorTime
|
||||
});
|
||||
void mapController.locateAndHighlight(mapTarget).then(() => {
|
||||
if (mapController.getSnapshot().errorCode) {
|
||||
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 mapTarget = event.mapTargets[0];
|
||||
if (mapTarget) {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-spatial-feature",
|
||||
featureId: mapTarget.featureId,
|
||||
sourceId: mapTarget.sourceId
|
||||
});
|
||||
void mapController.locateAndHighlight(mapTarget).then(() => {
|
||||
if (mapController.getSnapshot().errorCode) {
|
||||
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(() => {
|
||||
dispatchWorkspaceContext({ type: "clear-operational-event" });
|
||||
@@ -902,16 +963,18 @@ export function MapWorkbenchPage({
|
||||
onShowShortcuts: handleShowShortcuts,
|
||||
onExportConfig: handleExportConfig
|
||||
};
|
||||
const selectedOperationalEvent = operationalEvents.find(
|
||||
(event) => event.id === workspaceContext.operational.selectedEventId
|
||||
) ?? null;
|
||||
const selectedOperationalEvent =
|
||||
operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
|
||||
null;
|
||||
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
|
||||
const items: AgentWorkspaceContextItem[] = [{
|
||||
id: "operational-time",
|
||||
label: "运行时点",
|
||||
value: formatContextClock(workspaceContext.operational.cursorTime),
|
||||
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
|
||||
}];
|
||||
const items: AgentWorkspaceContextItem[] = [
|
||||
{
|
||||
id: "operational-time",
|
||||
label: "运行时点",
|
||||
value: formatContextClock(workspaceContext.operational.cursorTime),
|
||||
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
|
||||
}
|
||||
];
|
||||
|
||||
if (workspaceContext.spatial.featureId) {
|
||||
items.push({
|
||||
@@ -980,9 +1043,11 @@ export function MapWorkbenchPage({
|
||||
>
|
||||
<WorkbenchTopBar
|
||||
dataTime={displayedDataTime}
|
||||
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label
|
||||
|| agent.selectedModel
|
||||
|| "Agent 后端连接中"}
|
||||
modelName={
|
||||
agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label ||
|
||||
agent.selectedModel ||
|
||||
"Agent 后端连接中"
|
||||
}
|
||||
activeAnalysisArtifact={artifactWorkspace.activeArtifact}
|
||||
alerts={workbenchAlerts}
|
||||
alertDataSource={alertDataSource}
|
||||
@@ -999,8 +1064,14 @@ export function MapWorkbenchPage({
|
||||
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }}
|
||||
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }}
|
||||
onToggleAnalysisResultsPanel={() => {
|
||||
setPeripheralSamplePanelOpen(false);
|
||||
setAnalysisResultsPanelOpen((current) => !current);
|
||||
}}
|
||||
onTogglePeripheralSamplePanel={() => {
|
||||
setAnalysisResultsPanelOpen(false);
|
||||
setPeripheralSamplePanelOpen((current) => !current);
|
||||
}}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
latestAnalysisAvailable={latestAnalysisAvailable}
|
||||
@@ -1029,6 +1100,7 @@ export function MapWorkbenchPage({
|
||||
open={peripheralSamplePanelOpen}
|
||||
onClose={() => setPeripheralSamplePanelOpen(false)}
|
||||
getAccessToken={getAccessToken}
|
||||
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
|
||||
/>
|
||||
|
||||
<WorkbenchMainFrame
|
||||
@@ -1043,56 +1115,58 @@ export function MapWorkbenchPage({
|
||||
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
||||
timelineMode={timelineMode}
|
||||
timelineBottom={timelineBottom}
|
||||
timelineContent={(
|
||||
timelineVisible ? <>
|
||||
<div className="hidden lg:block">
|
||||
<OperationalTimeline
|
||||
date={workspaceContext.operational.date}
|
||||
cursorTime={workspaceContext.operational.cursorTime}
|
||||
events={operationalEvents}
|
||||
mode={timelineMode}
|
||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||
onModeChange={setTimelineMode}
|
||||
onSelectEvent={handleSelectOperationalEvent}
|
||||
onClearSelection={handleClearOperationalSelection}
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="region"
|
||||
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"
|
||||
timelineContent={
|
||||
timelineVisible ? (
|
||||
<>
|
||||
<div className="hidden lg:block">
|
||||
<OperationalTimeline
|
||||
date={workspaceContext.operational.date}
|
||||
cursorTime={workspaceContext.operational.cursorTime}
|
||||
events={operationalEvents}
|
||||
mode={timelineMode}
|
||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||
onModeChange={setTimelineMode}
|
||||
onSelectEvent={handleSelectOperationalEvent}
|
||||
onClearSelection={handleClearOperationalSelection}
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="region"
|
||||
aria-label="移动端运行时间轴摘要"
|
||||
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
|
||||
>
|
||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="shrink-0 font-semibold">运行时间轴</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
|
||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
||||
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
|
||||
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
|
||||
</span>
|
||||
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
|
||||
查看全天
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</> : null
|
||||
)}
|
||||
mapContent={(
|
||||
<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" />
|
||||
<span className="shrink-0 font-semibold">运行时间轴</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
|
||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
||||
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
|
||||
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
|
||||
</span>
|
||||
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
|
||||
查看全天
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
mapContent={
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid absolute inset-0"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
)}
|
||||
mapOverlay={(
|
||||
}
|
||||
mapOverlay={
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{!devPanelOpen ? (
|
||||
<>
|
||||
@@ -1106,22 +1180,25 @@ export function MapWorkbenchPage({
|
||||
) : null}
|
||||
<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)]"
|
||||
style={{
|
||||
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
|
||||
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
|
||||
} as CSSProperties}
|
||||
style={
|
||||
{
|
||||
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
|
||||
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<MapScaleLine
|
||||
className="hidden lg:inline-flex"
|
||||
mapRef={mapRef}
|
||||
mapReady={mapReady}
|
||||
/>
|
||||
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkbenchAgentPanels
|
||||
@@ -1307,11 +1384,11 @@ export function MapWorkbenchPage({
|
||||
{!mapReady && !mapError ? (
|
||||
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
|
||||
) : null}
|
||||
{!hasMapboxToken ? (
|
||||
{!hasOnlineBasemap ? (
|
||||
<MapSourceStatusNotice
|
||||
sourceName="Mapbox 底图"
|
||||
sourceName="在线底图"
|
||||
status="degraded"
|
||||
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
|
||||
message="未检测到 Mapbox 或天地图令牌,当前已切换为无底图模式,仅展示业务图层。"
|
||||
/>
|
||||
) : null}
|
||||
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
|
||||
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
|
||||
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 map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
|
||||
getBearing: () => bearing,
|
||||
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height }),
|
||||
getPitch: () => pitch
|
||||
} as unknown as MapLibreMap;
|
||||
|
||||
return { fitBounds, map };
|
||||
@@ -87,10 +89,20 @@ describe("fitNetworkBounds", () => {
|
||||
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
|
||||
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
|
||||
expect(options).toMatchObject({
|
||||
bearing: 0,
|
||||
duration: 600,
|
||||
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");
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ export function fitNetworkBounds(
|
||||
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
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 { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import {
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
MAP_LEGEND_ITEMS,
|
||||
applyBaseLayerVisibility,
|
||||
createLayerControlItems,
|
||||
createVisibleMapLegendItems,
|
||||
getWorkbenchLayerIds
|
||||
} from "./map-control-config";
|
||||
|
||||
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", () => {
|
||||
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
|
||||
fx_20200423_pipes: 215_797,
|
||||
fx_20200811_dn100_pipes: 122_686
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([
|
||||
"pipes",
|
||||
"junctions",
|
||||
"scada-pressure",
|
||||
"scada-flow",
|
||||
"water_quality_monitoring_points",
|
||||
"fx_20200423_pipes",
|
||||
"fx_20200811_dn100_pipes",
|
||||
"simulation"
|
||||
]));
|
||||
expect(items.map((item) => item.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"pipes",
|
||||
"junctions",
|
||||
"scada-pressure",
|
||||
"scada-flow",
|
||||
"water_quality_monitoring_points",
|
||||
"fx_20200423_pipes",
|
||||
"fx_20200811_dn100_pipes",
|
||||
"simulation"
|
||||
])
|
||||
);
|
||||
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
|
||||
group: "source-gis",
|
||||
groupLabel: "GIS 原始数据",
|
||||
@@ -99,15 +130,25 @@ describe("workbench supply layer controls", () => {
|
||||
"source-non-residential-meter",
|
||||
"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({
|
||||
label: "水库",
|
||||
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 === "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 === "pump")).toMatchObject({
|
||||
imageSrc: "/map/pump.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({
|
||||
color: MAP_STYLE_TOKENS.state.inactive,
|
||||
lineWidth: 2.5,
|
||||
@@ -133,7 +174,9 @@ describe("workbench supply layer controls", () => {
|
||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
|
||||
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"
|
||||
});
|
||||
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 initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
|
||||
|
||||
expect(initialLegendIds).toEqual(expect.arrayContaining([
|
||||
"major-pipe",
|
||||
"valve",
|
||||
"scada-flow",
|
||||
"water-quality-monitoring"
|
||||
]));
|
||||
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
|
||||
"source-major-pipe",
|
||||
"source-inventory-pipe",
|
||||
"impact-area"
|
||||
]));
|
||||
expect(initialLegendIds).toEqual(
|
||||
expect.arrayContaining(["major-pipe", "valve", "scada-flow", "water-quality-monitoring"])
|
||||
);
|
||||
expect(initialLegendIds).not.toEqual(
|
||||
expect.arrayContaining(["source-major-pipe", "source-inventory-pipe", "impact-area"])
|
||||
);
|
||||
|
||||
const sourcePipeItems = createLayerControlItems({
|
||||
...INITIAL_LAYER_VISIBILITY,
|
||||
fx_20200423_pipes: true
|
||||
});
|
||||
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map(
|
||||
(item) => item.id
|
||||
);
|
||||
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map((item) => item.id);
|
||||
|
||||
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([
|
||||
"source-major-pipe",
|
||||
"source-medium-pipe",
|
||||
"source-minor-pipe",
|
||||
"source-unknown-pipe"
|
||||
]));
|
||||
expect(sourcePipeLegendIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
"source-major-pipe",
|
||||
"source-medium-pipe",
|
||||
"source-minor-pipe",
|
||||
"source-unknown-pipe"
|
||||
])
|
||||
);
|
||||
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
|
||||
|
||||
const reducedItems = createLayerControlItems({
|
||||
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
SUPPLY_LAYER_CATALOG,
|
||||
type WaterNetworkSourceId
|
||||
} from "./sources";
|
||||
import {
|
||||
SCADA_ICON_PATHS,
|
||||
SCADA_LAYER_CONTROL_ITEMS,
|
||||
SCADA_LAYER_GROUPS
|
||||
} from "./scada";
|
||||
import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
|
||||
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
|
||||
|
||||
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> = {
|
||||
...Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.id !== "scada")
|
||||
.map((layer) => [layer.id, layer.initialVisible])
|
||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
|
||||
layer.id,
|
||||
layer.initialVisible
|
||||
])
|
||||
),
|
||||
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
|
||||
simulation: false
|
||||
};
|
||||
|
||||
const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.available)
|
||||
.map((layer) => [layer.id, layer.label])
|
||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [layer.id, layer.label])
|
||||
) as Record<WaterNetworkSourceId, string>;
|
||||
|
||||
type LayerControlPresentation = {
|
||||
@@ -55,7 +50,10 @@ type LayerControlPresentation = {
|
||||
visual: MapLayerControlVisual;
|
||||
};
|
||||
|
||||
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = {
|
||||
const LAYER_CONTROL_PRESENTATION: Record<
|
||||
Exclude<WaterNetworkSourceId, "scada">,
|
||||
LayerControlPresentation
|
||||
> = {
|
||||
pipes: {
|
||||
description: "按管径分级",
|
||||
minZoom: 8.5,
|
||||
@@ -258,14 +256,70 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
||||
layerControlId: "pipes",
|
||||
...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: "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: "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: "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",
|
||||
label: "DN600 及以上",
|
||||
@@ -311,36 +365,101 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
||||
layerControlId: "fx_20200811_dn100_pipes",
|
||||
...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-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 }
|
||||
{
|
||||
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-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(
|
||||
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
|
||||
) {
|
||||
const visibleLayerIds = new Set(
|
||||
layerItems.filter((item) => item.visible).map((item) => item.id)
|
||||
);
|
||||
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
|
||||
return MAP_LEGEND_ITEMS.filter((item) =>
|
||||
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) {
|
||||
const activeLayerIds = new Set<string>(
|
||||
BASE_LAYER_GROUPS[activeBaseLayerId as keyof typeof BASE_LAYER_GROUPS] ?? []
|
||||
);
|
||||
BASE_LAYER_IDS.forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(
|
||||
layerId,
|
||||
"visibility",
|
||||
activeBaseLayerId === layerId ? "visible" : "none"
|
||||
activeLayerIds.has(layerId) ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -359,6 +478,18 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
||||
description: "卫星街道",
|
||||
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",
|
||||
label: "无底图",
|
||||
@@ -390,20 +521,23 @@ export function createLayerControlItems(
|
||||
groupLabel: "管网模型",
|
||||
groupDescription: "计算模型与基础设施"
|
||||
})),
|
||||
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
minZoom: 8.5,
|
||||
visual: {
|
||||
kind: "image" as const,
|
||||
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
|
||||
},
|
||||
visible: layerVisibility[item.id],
|
||||
group: "monitoring",
|
||||
groupLabel: "监测点",
|
||||
groupDescription: "在线监测与分析选点"
|
||||
})) : []),
|
||||
...(available.has("scada")
|
||||
? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
minZoom: 8.5,
|
||||
visual: {
|
||||
kind: "image" as const,
|
||||
imageSrc:
|
||||
item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
|
||||
},
|
||||
visible: layerVisibility[item.id],
|
||||
group: "monitoring",
|
||||
groupLabel: "监测点",
|
||||
groupDescription: "在线监测与分析选点"
|
||||
}))
|
||||
: []),
|
||||
...monitoringItems.map((layer) => ({
|
||||
id: layer.id,
|
||||
label: SOURCE_CONTROL_LABELS[layer.id],
|
||||
@@ -443,8 +577,9 @@ export function getWorkbenchLayerIds(
|
||||
groupId: string
|
||||
) {
|
||||
if (groupId in SCADA_LAYER_GROUPS) {
|
||||
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS]
|
||||
.filter((layerId) => map.getLayer(layerId));
|
||||
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS].filter((layerId) =>
|
||||
map.getLayer(layerId)
|
||||
);
|
||||
}
|
||||
if (isWaterNetworkSourceId(groupId)) {
|
||||
return map
|
||||
|
||||
@@ -24,8 +24,9 @@ describe("createWaterNetworkSources", () => {
|
||||
});
|
||||
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
|
||||
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
|
||||
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points"))
|
||||
.toMatchObject({ available: true, availability: "probe", group: "monitoring" });
|
||||
expect(
|
||||
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.find((layer) => layer.id === "valves")).toMatchObject({
|
||||
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", () => {
|
||||
const sources = createWaterNetworkSources(["pipes", "junctions"]);
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
||||
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection
|
||||
} from "./scada-analysis";
|
||||
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
|
||||
import { SCADA_SOURCE_ID } from "./scada";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
@@ -280,7 +277,10 @@ export function isAvailableWaterNetworkSourceId(
|
||||
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(
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||
): 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"] = {
|
||||
[SCADA_SOURCE_ID]: {
|
||||
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 {
|
||||
version: 8,
|
||||
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,
|
||||
title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const,
|
||||
priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const,
|
||||
work_order_type: "general" as const, location: null, scenario_code: null,
|
||||
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
|
||||
created_by: "user", updated_by: "user"
|
||||
};
|
||||
|
||||
@@ -446,6 +446,8 @@ export interface components {
|
||||
CreateWorkOrder: {
|
||||
/** Assignee */
|
||||
assignee?: string | null;
|
||||
/** Location */
|
||||
location?: string | null;
|
||||
/**
|
||||
* Priority
|
||||
* @default normal
|
||||
@@ -459,6 +461,8 @@ export interface components {
|
||||
request_id: string;
|
||||
/** Scheduled At */
|
||||
scheduled_at?: string | null;
|
||||
/** Scenario Code */
|
||||
scenario_code?: string | null;
|
||||
/**
|
||||
* Status
|
||||
* @default draft
|
||||
@@ -469,6 +473,8 @@ export interface components {
|
||||
summary: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Work Order Type */
|
||||
work_order_type?: "general" | "water_quality_complaint";
|
||||
};
|
||||
/** DeleteResource */
|
||||
DeleteResource: {
|
||||
@@ -1066,6 +1072,8 @@ export interface components {
|
||||
created_at: string;
|
||||
/** Created By */
|
||||
created_by: string;
|
||||
/** Location */
|
||||
location?: string | null;
|
||||
/**
|
||||
* Origin
|
||||
* @constant
|
||||
@@ -1085,6 +1093,8 @@ export interface components {
|
||||
revision: number;
|
||||
/** Scheduled At */
|
||||
scheduled_at: string | null;
|
||||
/** Scenario Code */
|
||||
scenario_code?: string | null;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
@@ -1106,6 +1116,8 @@ export interface components {
|
||||
* Format: uuid
|
||||
*/
|
||||
work_order_id: string;
|
||||
/** Work Order Type */
|
||||
work_order_type?: "general" | "water_quality_complaint";
|
||||
};
|
||||
/** WorkOrderPage */
|
||||
WorkOrderPage: {
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Database,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -33,11 +33,12 @@ import type {
|
||||
type ResourceView = "orders" | "data";
|
||||
type Inventory = { workOrders: number; testData: number };
|
||||
|
||||
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange }: {
|
||||
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange, onAgentTrigger }: {
|
||||
client: PeripheralSampleClient;
|
||||
canWrite: boolean;
|
||||
capabilities: PeripheralContext["managed_resources"];
|
||||
onInventoryChange?: (inventory: Inventory) => void;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
}) {
|
||||
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
|
||||
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
|
||||
@@ -81,9 +82,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
|
||||
};
|
||||
|
||||
return <WorkbenchSection
|
||||
title="外围任务编排"
|
||||
description="一个任务包含多个子任务,每个子任务按派发、执行、复令推进。"
|
||||
icon={<GitBranch size={15} />}
|
||||
title="外围工单与测试数据"
|
||||
description="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
|
||||
icon={<ClipboardList size={15} />}
|
||||
tone="teal"
|
||||
bodyClassName="space-y-3"
|
||||
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
|
||||
@@ -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.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
|
||||
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<GitBranch size={13} />} onClick={() => setView("orders")}>任务结构</ResourceSwitch>
|
||||
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<ClipboardList size={13} />} onClick={() => setView("orders")}>工单列表</ResourceSwitch>
|
||||
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
|
||||
</div>
|
||||
{view === "orders" ? (
|
||||
capabilities.work_orders === "available"
|
||||
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
|
||||
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} onAgentTrigger={onAgentTrigger} />
|
||||
: null
|
||||
) : (
|
||||
capabilities.test_data === "available"
|
||||
@@ -133,8 +134,9 @@ function StoragePending({ resource, tables }: { resource: string; tables: string
|
||||
</WorkbenchNotice>;
|
||||
}
|
||||
|
||||
function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps & {
|
||||
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: ResourceProps & {
|
||||
orders: PeripheralWorkOrder[];
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
|
||||
@@ -142,6 +144,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
|
||||
const [summary, setSummary] = useState("");
|
||||
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
|
||||
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
|
||||
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{
|
||||
message: string;
|
||||
workOrder: PeripheralWorkOrder;
|
||||
} | null>(null);
|
||||
|
||||
const clear = () => {
|
||||
setEditing(null);
|
||||
@@ -163,29 +169,67 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
|
||||
setStatus(item.status);
|
||||
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>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">任务层级</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">管理主任务下的子任务及其阶段状态。</p></div>
|
||||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增子任务</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">测试工单</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张工单独立展示派发、执行和复令步骤,步骤状态互不混用。</p></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={createWaterQualityComplaint}>
|
||||
{busy === "create-water-quality-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
水质投诉演示
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
|
||||
<EditorHeading title={editing ? "编辑子任务" : "新增子任务"} onClose={clear} />
|
||||
{agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
|
||||
<p>{agentTriggerFailure.message}</p>
|
||||
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
|
||||
{busy === "retry-agent-trigger" ? <LoaderCircle className="animate-spin" /> : <RefreshCw size={13} />}重试触发
|
||||
</Button>
|
||||
</WorkbenchNotice> : null}
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title={editing ? "编辑工单" : "新增工单"} onClose={clear} />
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="子任务状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
|
||||
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
|
||||
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
|
||||
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
|
||||
]} /></div>
|
||||
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
|
||||
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
|
||||
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null });
|
||||
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null, work_order_type: "general", location: null, scenario_code: null });
|
||||
clear();
|
||||
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建子任务"}</Button>
|
||||
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建工单"}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
|
||||
</div> : null}
|
||||
<TaskHierarchy
|
||||
<WorkOrderList
|
||||
orders={orders}
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
@@ -198,36 +242,23 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TaskHierarchy({ orders, canWrite, busy, onEdit, onDelete }: {
|
||||
function WorkOrderList({ orders, canWrite, busy, onEdit, onDelete }: {
|
||||
orders: PeripheralWorkOrder[];
|
||||
canWrite: boolean;
|
||||
busy: string | null;
|
||||
onEdit: (item: PeripheralWorkOrder) => void;
|
||||
onDelete: (item: PeripheralWorkOrder) => void;
|
||||
}) {
|
||||
const completed = orders.filter((item) => item.status === "completed").length;
|
||||
const parentStatus = taskStatus(orders);
|
||||
return <section aria-label="任务层级" className="mt-3 overflow-hidden rounded-xl bg-slate-50/80 ring-1 ring-inset ring-slate-200">
|
||||
<header className="border-b border-cyan-100 bg-cyan-50/70 px-3.5 py-3 text-slate-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-xs ring-1 ring-inset ring-cyan-100"><ClipboardList size={16} /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-semibold text-cyan-700">任务</span><StatusBadge tone={parentStatus.tone}>{parentStatus.label}</StatusBadge></div>
|
||||
<h5 className="mt-1 truncate text-sm font-semibold">外围合成数据核查</h5>
|
||||
<p className="mt-1 text-[10px] text-slate-500"><span className="font-mono">DEMO-WO-001</span> · {orders.length} 个子任务 · {completed}/{orders.length} 完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{orders.length ? <ol className="relative space-y-2 p-3 before:absolute before:bottom-8 before:left-[25px] before:top-3 before:w-px before:bg-cyan-200">
|
||||
{orders.map((item, index) => <li key={item.work_order_id} className="relative pl-7">
|
||||
<span className="absolute left-0 top-3 z-10 grid size-5 place-items-center rounded-full bg-white text-[10px] font-semibold tabular-nums text-cyan-800 ring-1 ring-inset ring-cyan-300">{index + 1}</span>
|
||||
<SubtaskCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
|
||||
return <section aria-label="工单列表" className="mt-3">
|
||||
{orders.length ? <ol className="space-y-2">
|
||||
{orders.map((item, index) => <li key={item.work_order_id}>
|
||||
<WorkOrderCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
|
||||
</li>)}
|
||||
</ol> : <div className="p-3"><WorkbenchEmptyState icon={<Wrench size={15} />} title="当前任务还没有子任务" description="新增子任务后,可在这里查看每项任务的派发、执行和复令状态。" /></div>}
|
||||
</ol> : <WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="新增工单后,可在这里查看接口返回的工单字段和状态。" />}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
item: PeripheralWorkOrder;
|
||||
index: number;
|
||||
canWrite: boolean;
|
||||
@@ -235,47 +266,75 @@ function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
|
||||
onEdit: () => 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="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>
|
||||
<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 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>
|
||||
<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">
|
||||
<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>
|
||||
</article>;
|
||||
}
|
||||
|
||||
type StageStatus = "done" | "current" | "pending" | "skipped";
|
||||
type WorkOrderStepStatus = "done" | "current" | "pending" | "skipped";
|
||||
|
||||
function StageProgress({ status, title }: { status: PeripheralWorkOrder["status"]; title: string }) {
|
||||
const statuses = stageStatuses(status);
|
||||
const stages = ["派发", "执行", "复令"];
|
||||
return <div className="mt-3 rounded-lg bg-slate-50 px-2.5 py-2" aria-label={`子任务阶段 ${title}`}>
|
||||
<p className="mb-2 text-[10px] font-semibold text-slate-500">子任务阶段</p>
|
||||
<ol className="grid grid-cols-3">
|
||||
{stages.map((stage, index) => {
|
||||
const stageStatus = statuses[index];
|
||||
return <li key={stage} className="relative min-w-0 text-center">
|
||||
{index < stages.length - 1 ? <span className={cn("absolute left-[calc(50%+9px)] right-[calc(-50%+9px)] top-2 h-px", stageStatus === "done" ? "bg-cyan-400" : "bg-slate-300")} aria-hidden="true" /> : null}
|
||||
<span className={cn(
|
||||
"relative z-10 mx-auto grid size-4 place-items-center rounded-full text-[9px] font-semibold",
|
||||
stageStatus === "done" && "bg-cyan-700 text-white",
|
||||
stageStatus === "current" && "bg-white text-cyan-700 ring-2 ring-inset ring-cyan-500",
|
||||
stageStatus === "pending" && "bg-white text-slate-400 ring-1 ring-inset ring-slate-300",
|
||||
stageStatus === "skipped" && "bg-slate-200 text-slate-500"
|
||||
)}>{stageStatus === "done" ? <Check size={10} /> : stageStatus === "current" ? <CircleDot size={10} /> : stageStatus === "skipped" ? <Ban size={9} /> : index + 1}</span>
|
||||
<p className="mt-1 truncate text-[10px] font-semibold text-slate-700">{stage}</p>
|
||||
<p className={cn("truncate text-[9px]", stageStatus === "current" ? "font-semibold text-cyan-700" : stageStatus === "done" ? "text-cyan-700" : "text-slate-400")}>{stageStatusLabel(stageStatus)}</p>
|
||||
const WORK_ORDER_STEPS = [
|
||||
{ id: "dispatch", title: "派发", description: "确认处理班组与计划窗口" },
|
||||
{ id: "execution", title: "执行", description: "按工单要求处置并留痕" },
|
||||
{ id: "reply", title: "复令", description: "回填结果并完成调度复核" }
|
||||
] as const;
|
||||
|
||||
function WorkOrderSteps({ status, title }: {
|
||||
status: PeripheralWorkOrder["status"];
|
||||
title: string;
|
||||
}) {
|
||||
const statuses = workOrderStepStatuses(status);
|
||||
const completed = statuses.filter((item) => item === "done").length;
|
||||
|
||||
return <section aria-label={`工单步骤 ${title}`} className="mt-3 border-t border-slate-200/80 pt-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold text-slate-500">工单步骤</p>
|
||||
<span className="text-[10px] tabular-nums text-slate-400">{completed}/{WORK_ORDER_STEPS.length} 完成</span>
|
||||
</div>
|
||||
<ol className="grid gap-1.5">
|
||||
{WORK_ORDER_STEPS.map((step, index) => {
|
||||
const stepStatus = statuses[index];
|
||||
return <li key={step.id} className="grid min-w-0 grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-lg bg-white px-2 py-2 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
|
||||
<WorkOrderStepIcon status={stepStatus} step={index + 1} />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[11px] font-semibold text-slate-800">{step.title}</span>
|
||||
<span className="block truncate text-[10px] text-slate-400">{step.description}</span>
|
||||
</span>
|
||||
<StatusBadge tone={workOrderStepTone(stepStatus)}>{workOrderStepStatusLabel(stepStatus)}</StatusBadge>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
</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 & {
|
||||
@@ -316,7 +375,7 @@ function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
|
||||
|
||||
return <div>
|
||||
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
|
||||
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
|
||||
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
|
||||
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
|
||||
@@ -351,7 +410,7 @@ function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAd
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -366,21 +425,15 @@ function formatTimestamp(value: string) { return value.replace("T", " ").replace
|
||||
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
|
||||
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
|
||||
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
|
||||
function taskStatus(orders: PeripheralWorkOrder[]): { label: string; tone: StatusTone } {
|
||||
if (!orders.length) return { label: "待编排", tone: "neutral" };
|
||||
if (orders.every((item) => item.status === "completed")) return { label: "已完成", tone: "success" };
|
||||
if (orders.every((item) => item.status === "cancelled")) return { label: "已取消", tone: "danger" };
|
||||
if (orders.some((item) => item.status === "in_progress")) return { label: "进行中", tone: "info" };
|
||||
return { label: "待处理", tone: "warning" };
|
||||
}
|
||||
function stageStatuses(status: PeripheralWorkOrder["status"]): [StageStatus, StageStatus, StageStatus] {
|
||||
function workOrderStepStatuses(status: PeripheralWorkOrder["status"]): [WorkOrderStepStatus, WorkOrderStepStatus, WorkOrderStepStatus] {
|
||||
if (status === "completed") return ["done", "done", "done"];
|
||||
if (status === "in_progress") return ["done", "current", "pending"];
|
||||
if (status === "open") return ["current", "pending", "pending"];
|
||||
if (status === "cancelled") return ["skipped", "skipped", "skipped"];
|
||||
return ["pending", "pending", "pending"];
|
||||
}
|
||||
function 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 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]; }
|
||||
|
||||
@@ -18,14 +18,16 @@ import {
|
||||
type PeripheralContext,
|
||||
type PeripheralObservations,
|
||||
type PeripheralPointPage,
|
||||
type PeripheralScenario
|
||||
type PeripheralScenario,
|
||||
type PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
}) {
|
||||
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
|
||||
const client = clientRef.current;
|
||||
@@ -145,6 +147,7 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
onInventoryChange={setInventory}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<SyntheticDemoSection client={client} />
|
||||
<Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
|
||||
|
||||
@@ -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 事件任务"
|
||||
};
|
||||
}
|
||||
@@ -65,7 +65,8 @@ export type ScheduledConditionTaskId =
|
||||
| "scada-diagnosis"
|
||||
| "smart-dispatch"
|
||||
| "pump-energy"
|
||||
| "network-simulation";
|
||||
| "network-simulation"
|
||||
| "water-quality-complaint";
|
||||
|
||||
export type ScheduledConditionRecordTaskId =
|
||||
| ScheduledConditionTaskId
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_TIANDITU_TOKEN: "tianditu-token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
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_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_TIANDITU_TOKEN: "tianditu-token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||
@@ -47,6 +49,7 @@ describe("runtime frontend configuration", () => {
|
||||
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_TIANDITU_TOKEN: "",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "auto",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "",
|
||||
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo",
|
||||
@@ -59,10 +62,12 @@ describe("runtime frontend configuration", () => {
|
||||
});
|
||||
|
||||
it("accepts the explicit backend history mode", () => {
|
||||
expect(parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "history"
|
||||
}).TJWATER_WORKBENCH_RUNTIME_MODE).toBe("history");
|
||||
expect(
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "history"
|
||||
}).TJWATER_WORKBENCH_RUNTIME_MODE
|
||||
).toBe("history");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(() => parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T16:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T08:00:00Z"
|
||||
})).toThrow(/演示窗口/);
|
||||
expect(() => parseRuntimeConfig({
|
||||
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(/演示窗口/);
|
||||
expect(() =>
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T16:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T08:00:00Z"
|
||||
})
|
||||
).toThrow(/演示窗口/);
|
||||
expect(() =>
|
||||
parseRuntimeConfig({
|
||||
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(/演示窗口/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,12 +43,18 @@ const keycloakIssuer = z
|
||||
if (!value) return;
|
||||
const parsed = browserHttpUrl.safeParse(value);
|
||||
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;
|
||||
}
|
||||
const url = new URL(value);
|
||||
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(/\/+$/, ""));
|
||||
@@ -59,6 +65,7 @@ const runtimeConfigSchema = z
|
||||
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||
TJWATER_TIANDITU_TOKEN: z.string().default(""),
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
|
||||
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_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl,
|
||||
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"),
|
||||
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"),
|
||||
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: z.coerce.number().int().min(1).max(168).default(8),
|
||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -217,12 +217,34 @@ test.describe("外围样板管理", () => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible();
|
||||
const hierarchy = page.getByLabel("任务层级");
|
||||
await expect(hierarchy).toContainText("外围合成数据核查");
|
||||
await expect(hierarchy).toContainText("3 个子任务");
|
||||
await expect(page.getByLabel("子任务阶段 合成通信核查")).toContainText(/派发.*当前阶段.*执行.*待开始.*复令.*待开始/);
|
||||
await expect(page.getByLabel("子任务阶段 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前阶段.*复令.*待开始/);
|
||||
await expect(page.getByLabel("子任务阶段 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/);
|
||||
const workOrderList = page.getByLabel("工单列表");
|
||||
await expect(workOrderList).toContainText("合成通信核查");
|
||||
await expect(workOrderList).toContainText("合成压力偏差复核");
|
||||
await expect(workOrderList).toContainText("合成零流量样本确认");
|
||||
await expect(workOrderList).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: "场景" });
|
||||
await scenarioSelect.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -375,7 +397,7 @@ test.describe("外围样板管理", () => {
|
||||
expect(managedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("创建、修改并软删除测试工单和测试数据", async ({ page }) => {
|
||||
test("创建、修改并软删除测试工单和测试数据", async ({ page }, testInfo) => {
|
||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||
let workOrders: Record<string, unknown>[] = [];
|
||||
let testData: Record<string, unknown>[] = [];
|
||||
@@ -418,19 +440,33 @@ test.describe("外围样板管理", () => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围样板管理" });
|
||||
await expect(panel.getByText("外围任务编排", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("任务层级")).toContainText("外围合成数据核查");
|
||||
await expect(panel.getByText("外围工单与测试数据", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("工单列表")).toContainText("暂无测试工单");
|
||||
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("外围 CRUD 联调");
|
||||
await panel.getByRole("button", { name: "创建子任务" }).click();
|
||||
await panel.getByRole("button", { name: "创建工单" }).click();
|
||||
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("button", { name: "保存修改" }).click();
|
||||
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 panel.getByRole("group", { name: "外围样板资源类型" }).getByRole("button", { name: /测试数据/ }).click();
|
||||
@@ -447,6 +483,133 @@ test.describe("外围样板管理", () => {
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 375, height: 720 });
|
||||
await page.goto("/");
|
||||
|
||||
@@ -17,9 +17,51 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
description: "Playwright 浏览器测试",
|
||||
icon: "bolt" as const
|
||||
};
|
||||
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
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")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -8,7 +8,10 @@ export async function mockWorkbenchContext(
|
||||
projectPresentation?: { metadata: unknown; accessContext: unknown },
|
||||
analysisRuns: Record<string, unknown> = {},
|
||||
analysisHistory: unknown[] = [],
|
||||
options: { runtimeMode?: "auto" | "demo" | "history" | "backend" } = {}
|
||||
options: {
|
||||
runtimeMode?: "auto" | "demo" | "history" | "backend";
|
||||
tiandituToken?: string;
|
||||
} = {}
|
||||
) {
|
||||
await mockScadaApi(
|
||||
page,
|
||||
@@ -29,6 +32,7 @@ export async function mockWorkbenchContext(
|
||||
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
TJWATER_TIANDITU_TOKEN: options.tiandituToken ?? "",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: options.runtimeMode ?? "auto",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})};`
|
||||
|
||||
@@ -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 }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await openWorkbench(page);
|
||||
@@ -58,7 +87,9 @@ test.describe("Main Frame workbench", () => {
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||
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())?.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);
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||
animations: "disabled",
|
||||
@@ -123,6 +154,7 @@ async function prepareDeterministicWorkbench(page: Page) {
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||
TJWATER_TIANDITU_TOKEN: "visual-test-tianditu-token",
|
||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
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 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 route.fulfill({
|
||||
contentType: "application/vnd.mapbox-vector-tile",
|
||||
@@ -182,19 +217,39 @@ async function openWorkbench(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
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: label, exact: true }).click();
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
await expect(page.locator("main")).toHaveAttribute(
|
||||
"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) {
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
|
||||
|
||||
+4
-6
@@ -12,21 +12,19 @@ function renderRuntimeConfig(values: Record<string, string>) {
|
||||
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
||||
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_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_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_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_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_DEMO_START:
|
||||
values.TJWATER_PERIPHERAL_SAMPLE_DEMO_START || "2026-09-17T00:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_DEMO_END:
|
||||
values.TJWATER_PERIPHERAL_SAMPLE_DEMO_END || "2026-09-17T12:00:00Z",
|
||||
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS:
|
||||
values.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS || "8",
|
||||
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: values.TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS || "8",
|
||||
TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
|
||||
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user