From f9c71cc07651af254a210c0a76439bdb6dc4a1ca Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 8 Sep 2026 18:18:40 +0800 Subject: [PATCH] feat(workbench): align v2 GIS and SCADA API --- .env.example | 3 +- README.md | 5 +- docker-entrypoint.sh | 4 +- docs/ACRYLIC_DESIGN_INVENTORY.md | 227 ++++++++++++++++++ public/runtime-config.js | 3 +- src/app/app.e2e.ts | 1 + .../agent/frontend-action/index.test.ts | 2 +- .../agent/ui-envelope/map-actions.test.ts | 6 +- src/features/agent/ui-envelope/map-actions.ts | 27 +-- .../workbench/api/scada-client.test.ts | 73 ++++++ src/features/workbench/api/scada-client.ts | 109 +++++++++ .../workbench/components/map-dev-panel.tsx | 9 +- .../hooks/use-map-interactions.test.ts | 8 +- .../workbench/hooks/use-map-interactions.ts | 26 +- .../hooks/use-workbench-map-controller.ts | 9 +- .../workbench/hooks/use-workbench-map.ts | 80 +++++- src/features/workbench/map-workbench-page.tsx | 8 +- src/features/workbench/map/export-view.ts | 4 +- src/features/workbench/map/feature-adapter.ts | 33 ++- .../workbench/map/flow-overlay.test.ts | 6 +- src/features/workbench/map/layers.test.ts | 10 +- src/features/workbench/map/layers.ts | 62 ++++- src/features/workbench/map/map-colors.ts | 2 + .../workbench/map/map-control-config.test.ts | 29 ++- .../workbench/map/map-control-config.ts | 6 +- .../workbench/map/map-feature-query.test.ts | 20 +- .../workbench/map/map-feature-query.ts | 16 +- .../workbench/map/scada-analysis.test.ts | 24 +- src/features/workbench/map/scada-analysis.ts | 44 ++-- src/features/workbench/map/scada.test.ts | 9 +- src/features/workbench/map/scada.ts | 21 +- src/features/workbench/map/sources.test.ts | 47 ++-- src/features/workbench/map/sources.ts | 54 +++-- .../map/workbench-map-controller.test.ts | 10 +- .../workbench/map/workbench-map-controller.ts | 21 +- .../utils/feature-properties.test.ts | 68 +++--- .../workbench/utils/feature-properties.ts | 90 ++++--- src/shared/config/env.test.ts | 2 + src/shared/config/env.ts | 3 +- tests/browser/button-system.e2e.ts | 5 +- tests/browser/scada-api-source.e2e.ts | 53 ++++ tests/browser/support/mock-scada-api.ts | 27 +++ tests/browser/workbench-visual.e2e.ts | 5 +- tests/browser/workspace-main-frame.e2e.ts | 5 +- vite.config.ts | 3 +- 45 files changed, 1025 insertions(+), 254 deletions(-) create mode 100644 docs/ACRYLIC_DESIGN_INVENTORY.md create mode 100644 src/features/workbench/api/scada-client.test.ts create mode 100644 src/features/workbench/api/scada-client.ts create mode 100644 tests/browser/scada-api-source.e2e.ts create mode 100644 tests/browser/support/mock-scada-api.ts diff --git a/.env.example b/.env.example index d1435ef..3f0cd78 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater TJWATER_MAPBOX_ACCESS_TOKEN= TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver -TJWATER_GEOSERVER_WORKSPACE=tjwater +TJWATER_GEOSERVER_WORKSPACE=tjwater_next +TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_MSW=false diff --git a/README.md b/README.md index d73eb70..cd0c050 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 | `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` | 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_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | @@ -50,7 +51,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。 -Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 +业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 ## 常用命令 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c88e7ae..c0817e1 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,7 +7,8 @@ runtime_config=$(jq -cn \ --arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \ --arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ - --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \ + --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ + --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ --arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ --arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \ --arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \ @@ -18,6 +19,7 @@ runtime_config=$(jq -cn \ TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken, TJWATER_MAP_URL: $mapUrl, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, + TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl, TJWATER_ENABLE_DEV_PANEL: $enableDevPanel, TJWATER_ENABLE_MSW: $enableMsw diff --git a/docs/ACRYLIC_DESIGN_INVENTORY.md b/docs/ACRYLIC_DESIGN_INVENTORY.md new file mode 100644 index 0000000..b5ad775 --- /dev/null +++ b/docs/ACRYLIC_DESIGN_INVENTORY.md @@ -0,0 +1,227 @@ +# 亚克力设计类别与组件使用清单 + +本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。 + +## 总览 + +当前材质体系分为 3 组,共 13 个语义类: + +| 分组 | 数量 | 类别 | 是否使用背景模糊 | +|---|---:|---|---| +| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 | +| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 | +| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 | + +只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。 + +## 1. 外层亚克力与临时玻璃 + +这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。 + +### `acrylic-navigation` + +- 用途:全局导航栏。 +- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。 +- 特征:保留底部分隔和下投影,不使用顶部高光。 +- 当前组件: + - `WorkbenchTopBar`,工作台顶部 Header。 +- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。 + +### `acrylic-panel` + +- 用途:承载持续显示或需要集中阅读的主要浮动面板。 +- 模糊:使用面板级模糊,20px 至 24px。 +- 当前组件: + - `AgentCommandPanel`,Agent 命令面板外壳。 + - `AgentCollapsedRail`,Agent 收起状态侧栏。 + - `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。 + - `LayerControl`、`ControlPanel`、`Legend`、`BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。 + - `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。 + - `FeatureInsightPanel`,要素洞察面板。 + - `MapDevPanel`,地图开发调试面板。 + - `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。 + - `MapNotice`,地图通知。 +- 直接或间接涉及文件: + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-collapsed-rail.tsx` + - `src/features/map/core/components/map-control-styles.ts` + - `src/features/map/core/components/notice.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/feature-popover.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/map-dev-panel.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` + +### `acrylic-control` + +- 用途:始终悬浮在地图上的紧凑型独立控件。 +- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。 +- 当前组件: + - `Toolbar`,地图主工具栏。 + - `DrawToolbar`,绘制工具栏。 + - `ZoomControl`,缩放控件。 + - `ScaleLine`,比例尺。 + - `BaseLayersControl`,底图切换入口与预览控件。 + - `map-workbench-page.tsx` 中的浮动地图控制入口。 +- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。 +- 共享定义:`src/features/map/core/components/map-control-styles.ts`。 + +### `glass-transient` + +- 用途:短时出现、需要突出当前任务状态的玻璃浮层。 +- 模糊:沿用控件级模糊,16px 至 20px。 +- 当前组件: + - `AgentTaskTicker` 的活动任务卡片。 +- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。 +- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。 + +## 2. 内部实体表面 + +这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。 + +### `surface-dock` + +- 用途:预留给固定 Dock 或侧边停靠区域。 +- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。 +- 适用组件: + - 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。 + - 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。 + - 后续可能增加的对象目录、调度队列等常驻侧栏。 +- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`。 + +### `surface-control` + +- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。 +- 当前组件范围: + - Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。 + - 图层、量测和通用控制面板中的交互控件。 + - 预约任务面板与工况详情中的筛选器、元信息块和次级操作。 + - 任务浮条的非活动卡片。 + - 要素洞察面板的内部按钮。 + - Header 下拉菜单的内部控制区。 + - 通用 `IconButton`。 +- 主要文件: + - `src/features/agent/components/agent-collapsed-rail.tsx` + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-operational-brief.tsx` + - `src/features/map/core/components/control-panel.tsx` + - `src/features/map/core/components/layer-control.tsx` + - `src/features/map/core/components/measure-panel.tsx` + - `src/features/workbench/components/agent-task-ticker.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/scheduled-condition-detail-panel.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` + +### `surface-well` + +- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。 +- 当前组件: + - `LayerPanel`、`LayerControl` 的未激活条目。 + - `MeasurePanel`、`DrawToolbar`、`AnnotationPanel` 的内部区域。 + - `ControlPanel`、`Legend`、`BaseLayersControl` 的分组与空白区域。 + - Header 下拉菜单的普通菜单项。 +- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。 + +### `surface-reading` + +- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。 +- 当前组件范围: + - Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。 + - 预约任务列表、工况详情、KPI、报告内容与下拉列表。 + - 要素洞察、属性列表和底图名称标签。 + - Header 下拉菜单中的详情块与空状态。 +- 主要文件: + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-message-details.tsx` + - `src/features/agent/components/agent-operational-brief.tsx` + - `src/features/agent/components/agent-ui-envelope-renderer.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/scheduled-condition-detail-panel.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/feature-property-list.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` + - `src/features/map/core/components/base-layers-control.tsx` + +## 3. 语义状态表面 + +语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel`、`surface-control` 或 `surface-reading` 来建立界面层级。 + +| 类名 | 含义 | 当前使用位置 | +|---|---|---| +| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 | +| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 | +| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 | +| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 | +| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 | + +## 未使用样式的建议落点 + +### `surface-dock` + +建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用: + +- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。 +- `ScheduledConditionFeed` 或 `ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。 +- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。 + +如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`。 + +### `material-tone-danger` + +建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括: + +- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。 +- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。 +- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`。 +- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。 + +不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。 + +### `material-tone-agent` + +建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括: + +- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。 +- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。 +- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。 +- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。 +- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。 + +原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。 + +## 共享类映射 + +地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。 + +| 共享常量 | 实际材质类 | 典型用途 | +|---|---|---| +| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 | +| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 | +| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 | +| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 | +| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 | + +## 自适应与无障碍规则 + +- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。 +- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。 +- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。 +- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。 +- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。 + +## 使用约束 + +1. 一个浮动区域只允许最外层使用 `acrylic-*` 或 `glass-transient`。 +2. 外层亚克力内部使用 `surface-control`、`surface-well` 或 `surface-reading`,不要叠加第二层模糊。 +3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。 +4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。 +5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。 +6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。 + +## 当前待清理项 + +- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。 +- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。 +- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。 +- Agent 面板仍有少量局部类直接使用 `--surface-control`、`--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。 diff --git a/public/runtime-config.js b/public/runtime-config.js index 7abf66e..d4a46a2 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -1,5 +1,6 @@ globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "required", TJWATER_KEYCLOAK_ISSUER: "", - TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater" + TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", + TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000" }; diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 88974cb..8448e6d 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -17,6 +17,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { expect(runtimeConfig).toMatchObject({ TJWATER_MAP_URL: expect.any(String), TJWATER_GEOSERVER_WORKSPACE: expect.any(String), + TJWATER_SERVER_API_BASE_URL: expect.any(String), TJWATER_AGENT_API_BASE_URL: expect.any(String) }); expect(Object.keys(runtimeConfig as Record)).not.toContainEqual( diff --git a/src/features/agent/frontend-action/index.test.ts b/src/features/agent/frontend-action/index.test.ts index f99a6f9..c035e82 100644 --- a/src/features/agent/frontend-action/index.test.ts +++ b/src/features/agent/frontend-action/index.test.ts @@ -62,7 +62,7 @@ function createRequest(name: string) { toolCallId: "call-1", sessionId: "session-1", name, - params: { items: [{ sensor_id: "MP01", level: "high" }] }, + params: { items: [{ device_id: "MP01", level: "high" }] }, issuedAt: Date.now(), expiresAt: Date.now() + 15_000 }; diff --git a/src/features/agent/ui-envelope/map-actions.test.ts b/src/features/agent/ui-envelope/map-actions.test.ts index c31e308..eafbd3a 100644 --- a/src/features/agent/ui-envelope/map-actions.test.ts +++ b/src/features/agent/ui-envelope/map-actions.test.ts @@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => { expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({ type: "locate_features", featureIds: ["P1", "P2"], - layer: "geo_pipes_mat", + layer: "pipes", fallbackText: undefined }); }); @@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => { expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({ type: "locate_features", featureIds: ["42"], - layer: "geo_junctions_mat" + layer: "junctions" }); }); @@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => { expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({ type: "locate_features", featureIds: ["S-1"], - layer: "geo_scada" + layer: "scada" }); }); diff --git a/src/features/agent/ui-envelope/map-actions.ts b/src/features/agent/ui-envelope/map-actions.ts index 70ae5a1..4f90d9d 100644 --- a/src/features/agent/ui-envelope/map-actions.ts +++ b/src/features/agent/ui-envelope/map-actions.ts @@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record { } const LEGACY_LOCATE_ACTION_LAYERS: Record = { - locate_junctions: "geo_junctions_mat", - locate_pipes: "geo_pipes_mat", - locate_valves: "geo_valves", - locate_reservoirs: "geo_reservoirs", - locate_scada: "geo_scada", - locate_pumps: "geo_pumps", - locate_tanks: "geo_tanks" + locate_junctions: "junctions", + locate_pipes: "pipes", + locate_valves: "valves", + locate_reservoirs: "reservoirs", + locate_scada: "scada", + locate_pumps: "pumps", + locate_tanks: "tanks" }; const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; -const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"]; +const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"]; const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS); const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]); const ALLOWED_LOCATE_LAYERS = new Set([ - ...SUPPLY_SOURCE_IDS, - "pumps", - "tanks", - "geo_junctions_mat", - "geo_pipes_mat", - "geo_valves", - "geo_reservoirs", - "geo_scada", - "geo_pumps", - "geo_tanks" + ...SUPPLY_SOURCE_IDS ]); const WEB_MERCATOR_RADIUS = 6378137; diff --git a/src/features/workbench/api/scada-client.test.ts b/src/features/workbench/api/scada-client.test.ts new file mode 100644 index 0000000..be72780 --- /dev/null +++ b/src/features/workbench/api/scada-client.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchScadaDeviceCollection } from "./scada-client"; + +const device = { + device_id: "SCADA-1", + device_type: "pressure", + node_id: "J-1", + link_id: null, + api_query_id: "pressure-query", + transmission_mode: "realtime", + transmission_frequency: "2s", + reliability: 100, + x: 10, + y: 20, + longitude: 121.5, + latitude: 30.9 +}; + +describe("SCADA API client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves the active project and builds GeoJSON keyed by device_id", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }], + total: 1, + limit: 1000, + offset: 0 + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + items: [device, { ...device, device_id: "SCADA-NO-LOCATION", longitude: null }], + total: 2, + limit: 1000, + offset: 0 + }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const getAccessToken = vi.fn().mockResolvedValue("access-token"); + + const collection = await fetchScadaDeviceCollection(getAccessToken); + + expect(collection.features).toEqual([ + expect.objectContaining({ + id: "SCADA-1", + geometry: { type: "Point", coordinates: [121.5, 30.9] }, + properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" }) + }) + ]); + expect(getAccessToken).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); + const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); + expect(projectHeaders.get("Authorization")).toBe("Bearer access-token"); + expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token"); + expect(deviceHeaders.get("X-Project-Id")).toBe("project-1"); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0"); + }); + + it("does not query devices when no active project matches the workspace", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + items: [{ project_id: "project-1", gs_workspace: "other", status: "active" }], + total: 1, + limit: 1000, + offset: 0 + }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/workbench/api/scada-client.ts b/src/features/workbench/api/scada-client.ts new file mode 100644 index 0000000..df55246 --- /dev/null +++ b/src/features/workbench/api/scada-client.ts @@ -0,0 +1,109 @@ +import type { Feature, FeatureCollection, Point } from "geojson"; +import { z } from "zod"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { env } from "@/shared/config/env"; +import { GEOSERVER_WORKSPACE } from "../map/geoserver-config"; + +const projectSchema = z.object({ + project_id: z.string().min(1), + gs_workspace: z.string().min(1), + status: z.string() +}); +const projectPageSchema = z.object({ + items: z.array(projectSchema), + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative() +}); + +const scadaDeviceSchema = z.object({ + device_id: z.string().trim().min(1), + device_type: z.string(), + node_id: z.string().nullable(), + link_id: z.string().nullable(), + api_query_id: z.string().nullable(), + transmission_mode: z.string(), + transmission_frequency: z.string(), + reliability: z.number().int().nullable(), + x: z.number().nullable(), + y: z.number().nullable(), + longitude: z.number().nullable(), + latitude: z.number().nullable() +}); + +const scadaDevicePageSchema = z.object({ + items: z.array(scadaDeviceSchema), + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative() +}); + +export type ScadaDevice = z.infer; + +const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, ""); + +export async function fetchScadaDeviceCollection( + getAccessToken?: AccessTokenProvider, + signal?: AbortSignal +): Promise> { + const accessToken = await getAccessToken?.(); + const projects = projectPageSchema.parse( + await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal) + ); + const project = projects.items.find( + (item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE + ); + if (!project) { + throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`); + } + + const page = scadaDevicePageSchema.parse( + await requestJson( + "/api/v1/scada-devices?limit=1000&offset=0", + accessToken, + project.project_id, + signal + ) + ); + if (page.total > page.items.length) { + throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`); + } + + return { + type: "FeatureCollection", + features: page.items.flatMap(toScadaFeature) + }; +} + +function toScadaFeature(device: ScadaDevice): Array> { + if (device.longitude === null || device.latitude === null) return []; + return [{ + type: "Feature", + id: device.device_id, + geometry: { + type: "Point", + coordinates: [device.longitude, device.latitude] + }, + properties: device + }]; +} + +async function requestJson( + path: string, + accessToken: string | null | undefined, + projectId: string | undefined, + signal: AbortSignal | undefined +) { + const headers = new Headers({ Accept: "application/json" }); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + if (projectId) headers.set("X-Project-Id", projectId); + const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + const detail = payload && typeof payload === "object" && "detail" in payload + ? String(payload.detail) + : `HTTP ${response.status}`; + throw new Error(`SCADA API请求失败:${detail}`); + } + return payload; +} diff --git a/src/features/workbench/components/map-dev-panel.tsx b/src/features/workbench/components/map-dev-panel.tsx index 6acaf8a..4e5d7bc 100644 --- a/src/features/workbench/components/map-dev-panel.tsx +++ b/src/features/workbench/components/map-dev-panel.tsx @@ -32,7 +32,9 @@ const GROUP_LABELS: Record = { junctions: "节点", valves: "阀门", reservoirs: "水库", - scada: "SCADA" + scada: "SCADA", + pumps: "水泵", + tanks: "水箱" }; const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]; @@ -63,7 +65,7 @@ const LABEL_PRESETS: Record< unit: "m", options: [ { value: "elevation", label: "高程 elevation" }, - { value: "demand", label: "需水量 demand" } + { value: "base_demand", label: "基础需水量 base_demand" } ] }, valves: { @@ -88,6 +90,9 @@ const ERROR_LABELS: Record = { MAP_NOT_READY: "地图尚未就绪", FEATURE_NOT_FOUND: "目标要素不存在", WFS_UNAVAILABLE: "WFS 查询暂不可用", + SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用", + SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备", + ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代", FLOW_UNAVAILABLE: "水流图层初始化失败", INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效", INVALID_STYLE_PATCH: "参数不符合受控范围" diff --git a/src/features/workbench/hooks/use-map-interactions.test.ts b/src/features/workbench/hooks/use-map-interactions.test.ts index 060bb68..09af270 100644 --- a/src/features/workbench/hooks/use-map-interactions.test.ts +++ b/src/features/workbench/hooks/use-map-interactions.test.ts @@ -7,7 +7,7 @@ import { import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers"; describe("map feature interaction state", () => { - const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const; + const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const; it("maps selected business features to promoted vector feature references", () => { expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature); @@ -18,7 +18,7 @@ describe("map feature interaction state", () => { const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; setMapFeatureInteractionState(map, feature, { selected: true, hovered: false }); expect(map.setFeatureState).toHaveBeenCalledWith( - { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, + { source: "junctions", sourceLayer: "junctions", id: "junction-7" }, { selected: true, hovered: false } ); }); @@ -27,7 +27,7 @@ describe("map feature interaction state", () => { const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; clearMapFeatureInteractionState(map, feature, "selected"); expect(map.removeFeatureState).toHaveBeenCalledWith( - { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, + { source: "junctions", sourceLayer: "junctions", id: "junction-7" }, "selected" ); }); @@ -38,6 +38,8 @@ describe("map feature interaction state", () => { "junctions-hit", "valves-hit", "reservoirs-hit", + "pumps-hit", + "tanks-hit", "scada-hit" ]); }); diff --git a/src/features/workbench/hooks/use-map-interactions.ts b/src/features/workbench/hooks/use-map-interactions.ts index e1585cc..17adf8e 100644 --- a/src/features/workbench/hooks/use-map-interactions.ts +++ b/src/features/workbench/hooks/use-map-interactions.ts @@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources"; export type MapFeatureInteractionState = { source: WaterNetworkSourceId; - sourceLayer: string; + sourceLayer?: string; id: string; hovered: boolean; selected: boolean; @@ -25,7 +25,9 @@ export function toMapFeatureReference( feature: Pick | null ): FeatureReference | null { if (!feature?.id) return null; - return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id }; + return feature.layer === "scada" + ? { source: feature.layer, id: feature.id } + : { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id }; } export function setMapFeatureInteractionState( @@ -33,10 +35,7 @@ export function setMapFeatureInteractionState( feature: FeatureReference, state: Partial> ) { - map.setFeatureState( - { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }, - state - ); + map.setFeatureState(toFeatureStateTarget(feature), state); } export function clearMapFeatureInteractionState( @@ -44,10 +43,7 @@ export function clearMapFeatureInteractionState( feature: FeatureReference, key?: "hovered" | "selected" ) { - map.removeFeatureState( - { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }, - key - ); + map.removeFeatureState(toFeatureStateTarget(feature), key); } type UseMapInteractionsOptions = { @@ -81,7 +77,9 @@ export function useMapInteractions({ const id = feature && getFeatureId(feature); if (!source || !id) return; - const next = { source, sourceLayer: SOURCE_LAYERS[source], id }; + const next = source === "scada" + ? { source, id } + : { source, sourceLayer: SOURCE_LAYERS[source], id }; if (selectedFeature?.id === id && selectedFeature.layer === source) { clearHoveredFeature(); return; @@ -127,3 +125,9 @@ export function useMapInteractions({ }; }, [mapReady, mapRef, selectedFeature]); } + +function toFeatureStateTarget(feature: FeatureReference) { + return feature.sourceLayer + ? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id } + : { source: feature.source, id: feature.id }; +} diff --git a/src/features/workbench/hooks/use-workbench-map-controller.ts b/src/features/workbench/hooks/use-workbench-map-controller.ts index ef9cb30..d3d6b1a 100644 --- a/src/features/workbench/hooks/use-workbench-map-controller.ts +++ b/src/features/workbench/hooks/use-workbench-map-controller.ts @@ -1,4 +1,6 @@ +import type { FeatureCollection, Point } from "geojson"; import type { Map as MapLibreMap } from "maplibre-gl"; +import type { ScadaDevice } from "../api/scada-client"; import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"; import { getResponsiveWorkbenchPadding } from "../map/camera"; import { WorkbenchMapController } from "../map/workbench-map-controller"; @@ -9,7 +11,8 @@ export function useWorkbenchMapController({ leftPanelOpen, rightPanelOpen, conditionPanelExpanded = false, - agentPanelWidth + agentPanelWidth, + getScadaFeatures }: { mapRef: RefObject; mapReady: boolean; @@ -17,6 +20,7 @@ export function useWorkbenchMapController({ rightPanelOpen: boolean; conditionPanelExpanded?: boolean; agentPanelWidth?: number; + getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise>; }) { const valuesRef = useRef({ mapReady, @@ -48,7 +52,8 @@ export function useWorkbenchMapController({ valuesRef.current.agentPanelWidth ) : { top: 48, right: 48, bottom: 48, left: 48 }; - } + }, + getScadaFeatures }); } diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts index 1feca21..c2283e9 100644 --- a/src/features/workbench/hooks/use-workbench-map.ts +++ b/src/features/workbench/hooks/use-workbench-map.ts @@ -1,8 +1,11 @@ import "maplibre-gl/dist/maplibre-gl.css"; -import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl"; -import { useEffect, useRef, useState, type RefObject } from "react"; +import type { FeatureCollection, Point } from "geojson"; +import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; +import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client"; import type { DetailFeature } from "../types"; import { SIMULATION_SOURCE_IDS, @@ -20,11 +23,13 @@ import { createValueLabelLayers } from "../map/value-label"; import { setSimulationLayersVisibility } from "../map/simulation-layers"; import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, + GEOSERVER_WATER_NETWORK_SOURCE_IDS, createBaseStyle, createWaterNetworkSources, WATER_NETWORK_GLOBAL_VIEW } from "../map/sources"; import { + SCADA_SOURCE_ID, registerScadaImages, scadaBusinessLayers, scadaFallbackBusinessLayers, @@ -46,6 +51,7 @@ type UseWorkbenchMapOptions = { impactVisible: boolean; onSelectFeature: (feature: DetailFeature) => void; selectedFeature: DetailFeature | null; + getAccessToken?: AccessTokenProvider; }; type UseWorkbenchMapResult = { @@ -53,6 +59,7 @@ type UseWorkbenchMapResult = { mapReady: boolean; mapError: string | null; sourceStatuses: WorkbenchSourceStatus[]; + getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise>; fitNetworkBounds: () => void; }; @@ -68,6 +75,7 @@ export type WorkbenchSourceStatus = { const SOURCE_STATUS_LABELS: Record = { "mapbox-base": "Mapbox 底图", "geoserver-mvt": "GeoServer MVT", + "scada-api": "SCADA API", "scada-icons": "SCADA 图标", "annotation-source": "业务标注源" }; @@ -76,7 +84,7 @@ const SOURCE_GROUP_BY_ID: Record = { "mapbox-light": "mapbox-base", "mapbox-satellite": "mapbox-base", ...Object.fromEntries( - AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"]) + GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"]) ), [SIMULATION_SOURCE_IDS.impactArea]: "annotation-source", [SIMULATION_SOURCE_IDS.annotations]: "annotation-source" @@ -86,13 +94,32 @@ export function useWorkbenchMap({ containerRef, impactVisible, onSelectFeature, - selectedFeature + selectedFeature, + getAccessToken }: UseWorkbenchMapOptions): UseWorkbenchMapResult { const mapRef = useRef(null); const impactVisibleRef = useRef(impactVisible); const [mapReady, setMapReady] = useState(false); const [mapError, setMapError] = useState(null); const [sourceStatuses, setSourceStatuses] = useState>({}); + const scadaCollectionRef = useRef>({ + type: "FeatureCollection", + features: [] + }); + const scadaLoadPromiseRef = useRef>>( + Promise.resolve(scadaCollectionRef.current) + ); + + const getScadaFeatures = useCallback(async (deviceIds?: string[], signal?: AbortSignal) => { + const collection = await scadaLoadPromiseRef.current; + if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError"); + if (!deviceIds) return collection; + const selectedIds = new Set(deviceIds); + return { + type: "FeatureCollection" as const, + features: collection.features.filter((feature) => selectedIds.has(String(feature.id))) + }; + }, []); useEffect(() => { impactVisibleRef.current = impactVisible; @@ -104,6 +131,13 @@ export function useWorkbenchMap({ } const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined; + const scadaAbortController = new AbortController(); + updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。"); + const scadaLoadPromise = fetchScadaDeviceCollection( + getAccessToken, + scadaAbortController.signal + ); + scadaLoadPromiseRef.current = scadaLoadPromise; const map = new maplibregl.Map({ container: containerRef.current, style: createBaseStyle(mapboxToken), @@ -118,8 +152,11 @@ export function useWorkbenchMap({ map.on("load", async () => { map.resize(); + (map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData( + scadaCollectionRef.current + ); const sources = createWaterNetworkSources(); - AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => + GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]) ); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); @@ -166,6 +203,25 @@ export function useWorkbenchMap({ fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); }); + void scadaLoadPromise.then((collection) => { + scadaCollectionRef.current = collection; + (map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection); + updateSourceStatus( + setSourceStatuses, + "scada-api", + "online", + `已通过API加载 ${collection.features.length} 个SCADA设备。` + ); + }).catch((error) => { + if (scadaAbortController.signal.aborted) return; + updateSourceStatus( + setSourceStatuses, + "scada-api", + "offline", + error instanceof Error ? error.message : "SCADA API加载失败。" + ); + }); + let resizeFrame: number | null = null; const resizeMap = () => map.resize(); const resizeObserver = new ResizeObserver(() => { @@ -213,11 +269,12 @@ export function useWorkbenchMap({ window.cancelAnimationFrame(resizeFrame); } resizeObserver.disconnect(); + scadaAbortController.abort(); map.remove(); mapRef.current = null; delete window.__waterNetworkMap; }; - }, [containerRef]); + }, [containerRef, getAccessToken]); useMapInteractions({ mapRef, @@ -244,6 +301,7 @@ export function useWorkbenchMap({ mapReady, mapError, sourceStatuses: Object.values(sourceStatuses), + getScadaFeatures, fitNetworkBounds: fitToNetworkBounds }; } @@ -338,6 +396,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt : "业务标注源加载中断,模拟标注可能暂不可见。"; } + if (sourceGroupId === "scada-api") { + return status === "online" + ? "SCADA设备已通过API加载。" + : "SCADA API请求中断,管网图层仍可继续使用。"; + } + return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。"; } @@ -354,5 +418,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) { return `业务标注源请求失败:${errorMessage}`; } + if (sourceGroupId === "scada-api") { + return `SCADA API请求失败:${errorMessage}`; + } + return errorMessage; } diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 81357fe..e56c97e 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -259,11 +259,12 @@ export function MapWorkbenchPage({ }); } - const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ + const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({ containerRef: mapContainerRef, impactVisible, onSelectFeature: handleSelectFeature, - selectedFeature: detailFeature + selectedFeature: detailFeature, + getAccessToken }); const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ mapRef, @@ -271,7 +272,8 @@ export function MapWorkbenchPage({ leftPanelOpen: false, rightPanelOpen, conditionPanelExpanded: rightPanelExpanded, - agentPanelWidth + agentPanelWidth, + getScadaFeatures }); const activeAgentUiResults = useMemo( diff --git a/src/features/workbench/map/export-view.ts b/src/features/workbench/map/export-view.ts index 049b939..6deee98 100644 --- a/src/features/workbench/map/export-view.ts +++ b/src/features/workbench/map/export-view.ts @@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd }); if (selectedFeature) { exportMap.setFeatureState( - { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }, + selectedFeature.sourceLayer + ? { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id } + : { source: selectedFeature.source, id: selectedFeature.id }, { selected: true, hovered: false } ); } diff --git a/src/features/workbench/map/feature-adapter.ts b/src/features/workbench/map/feature-adapter.ts index 6c2bad8..67a5b8e 100644 --- a/src/features/workbench/map/feature-adapter.ts +++ b/src/features/workbench/map/feature-adapter.ts @@ -1,15 +1,19 @@ import type { MapGeoJSONFeature } from "maplibre-gl"; import type { DetailFeature } from "../types"; import { formatValue } from "../utils/format-value"; -import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; +import { + SOURCE_LAYERS, + type GeoServerWaterNetworkSourceId, + type WaterNetworkSourceId +} from "./sources"; export function getFeatureId(feature: MapGeoJSONFeature) { - const raw = feature.properties?.id ?? feature.id; + const raw = feature.properties?.device_id ?? feature.properties?.id ?? feature.id; return raw === undefined || raw === null ? "" : String(raw); } export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null { - for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) { + for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) { if (feature.source === sourceId || feature.sourceLayer === sourceLayer) { return sourceId; } @@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties if (sourceId === "valves") { return { title: `阀门 ${id || "未命名"}`, - subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}` + subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}` }; } if (sourceId === "reservoirs") { return { title: `水库 ${id || "未命名"}`, - subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}` + subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}` }; } if (sourceId === "scada") { + const associatedElementId = properties.node_id ?? properties.link_id; return { title: `SCADA ${id || "未命名"}`, - subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}` + subtitle: `${formatValue(properties.device_type)} · 关联 ${formatValue(associatedElementId)}` + }; + } + + if (sourceId === "pumps") { + return { + title: `水泵 ${id || "未命名"}`, + subtitle: `${formatValue(properties.start_node_id)} → ${formatValue(properties.end_node_id)}` + }; + } + + if (sourceId === "tanks") { + return { + title: `水箱 ${id || "未命名"}`, + subtitle: `初始水位 ${formatValue(properties.initial_level)} · 高程 ${formatValue(properties.elevation)} m` }; } return { title: `节点 ${id || "未命名"}`, - subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m` + subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m` }; } diff --git a/src/features/workbench/map/flow-overlay.test.ts b/src/features/workbench/map/flow-overlay.test.ts index 610b6e1..2ffe61e 100644 --- a/src/features/workbench/map/flow-overlay.test.ts +++ b/src/features/workbench/map/flow-overlay.test.ts @@ -27,12 +27,12 @@ describe("flow overlay", () => { expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ source: "pipes", - "source-layer": "geo_pipes_mat", + "source-layer": "pipes", paint: { "line-pattern": "workbench-network-flow-pattern-forward" } }); expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({ source: "pipes", - "source-layer": "geo_pipes_mat", + "source-layer": "pipes", paint: { "line-pattern": "workbench-network-flow-pattern-reverse" } }); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); @@ -56,7 +56,7 @@ describe("flow overlay", () => { expect(map.setFeatureState).toHaveBeenNthCalledWith(1, { source: "pipes", - sourceLayer: "geo_pipes_mat", + sourceLayer: "pipes", id: "P-1" }, { flowDirection: -1 diff --git a/src/features/workbench/map/layers.test.ts b/src/features/workbench/map/layers.test.ts index 47bccf5..db212c1 100644 --- a/src/features/workbench/map/layers.test.ts +++ b/src/features/workbench/map/layers.test.ts @@ -34,6 +34,14 @@ describe("supply network layer styling", () => { minzoom: 8.5, layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir } }); + expect(getLayer("pumps-symbol")).toMatchObject({ + minzoom: 11, + layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump } + }); + expect(getLayer("tanks-symbol")).toMatchObject({ + minzoom: 11, + layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank } + }); expect(getLayer("junctions")).toMatchObject({ minzoom: 15 }); const valvePaint = getLayer("valves")?.paint as Record | undefined; @@ -47,7 +55,7 @@ describe("supply network layer styling", () => { const ids = waterNetworkLayers.map((layer) => layer.id); expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover")); expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit")); - expect(ids.at(-1)).toBe("reservoirs-hit"); + expect(ids.at(-1)).toBe("tanks-hit"); }); }); diff --git a/src/features/workbench/map/layers.ts b/src/features/workbench/map/layers.ts index 7603c0b..3b9260d 100644 --- a/src/features/workbench/map/layers.ts +++ b/src/features/workbench/map/layers.ts @@ -22,6 +22,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [ "junctions-hit", "valves-hit", "reservoirs-hit", + "pumps-hit", + "tanks-hit", SCADA_HIT_LAYER_ID ] as const; @@ -208,6 +210,38 @@ layers.push( paint: { "icon-opacity": supplyIconOpacityExpression } + }, + { + id: "pumps-symbol", + type: "symbol", + source: "pumps", + "source-layer": SOURCE_LAYERS.pumps, + minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom, + layout: { + "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump, + "icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize, + "icon-allow-overlap": true, + "icon-ignore-placement": true + }, + paint: { + "icon-opacity": supplyIconOpacityExpression + } + }, + { + id: "tanks-symbol", + type: "symbol", + source: "tanks", + "source-layer": SOURCE_LAYERS.tanks, + minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom, + layout: { + "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank, + "icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize, + "icon-allow-overlap": true, + "icon-ignore-placement": true + }, + paint: { + "icon-opacity": supplyIconOpacityExpression + } } ); @@ -295,7 +329,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) => const pointInteractions = [ ["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction], ["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve], - ["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir] + ["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir], + ["pumps", "pumps", SOURCE_LAYERS.pumps, SUPPLY_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.supply.pump], + ["tanks", "tanks", SOURCE_LAYERS.tanks, SUPPLY_LAYER_VISUALS.tanks, MAP_STYLE_TOKENS.supply.tank] ] as const; pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => { @@ -403,6 +439,30 @@ layers.push( "circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius, "circle-opacity": 0 } + }, + { + id: "pumps-hit", + type: "circle", + source: "pumps", + "source-layer": SOURCE_LAYERS.pumps, + minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom, + paint: { + "circle-color": MAP_STYLE_TOKENS.canvas.transparent, + "circle-radius": SUPPLY_LAYER_VISUALS.pumps.hitRadius, + "circle-opacity": 0 + } + }, + { + id: "tanks-hit", + type: "circle", + source: "tanks", + "source-layer": SOURCE_LAYERS.tanks, + minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom, + paint: { + "circle-color": MAP_STYLE_TOKENS.canvas.transparent, + "circle-radius": SUPPLY_LAYER_VISUALS.tanks.hitRadius, + "circle-opacity": 0 + } } ); diff --git a/src/features/workbench/map/map-colors.ts b/src/features/workbench/map/map-colors.ts index 4ebe428..218ede1 100644 --- a/src/features/workbench/map/map-colors.ts +++ b/src/features/workbench/map/map-colors.ts @@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = { junction: "#47787B", valve: "#6B7280", reservoir: "#195F84", + pump: "#326B87", + tank: "#4F6F7A", scadaFlow: "#007A9E", scadaPressure: "#9A6700" }, diff --git a/src/features/workbench/map/map-control-config.test.ts b/src/features/workbench/map/map-control-config.test.ts index 3c48e44..8c61c43 100644 --- a/src/features/workbench/map/map-control-config.test.ts +++ b/src/features/workbench/map/map-control-config.test.ts @@ -19,16 +19,18 @@ describe("workbench supply layer controls", () => { "valves", "reservoirs", "scada", + "pumps", + "tanks", "simulation" ]); - expect(items.map((item) => item.id)).not.toContain("pumps"); - expect(items.map((item) => item.id)).not.toContain("tanks"); - expect(items.slice(0, 5).map((item) => item.description)).toEqual([ - "tjwater:geo_pipes_mat", - "tjwater:geo_junctions_mat", - "tjwater:geo_valves", - "tjwater:geo_reservoirs", - "tjwater:geo_scada" + expect(items.slice(0, 7).map((item) => item.description)).toEqual([ + "tjwater_next:pipes", + "tjwater_next:junctions", + "tjwater_next:valves", + "tjwater_next:reservoirs", + "后端业务 API", + "tjwater_next:pumps", + "tjwater_next:tanks" ]); }); @@ -41,6 +43,8 @@ describe("workbench supply layer controls", () => { "junction", "valve", "reservoir", + "pump", + "tank", "scada-flow", "scada-pressure", "impact-area" @@ -50,6 +54,8 @@ describe("workbench supply layer controls", () => { 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 === "inactive-pipe")).toMatchObject({ @@ -82,5 +88,12 @@ describe("workbench supply layer controls", () => { "reservoirs-selected", "reservoirs-hit" ]); + expect(getWorkbenchLayerIds(map, "pumps")).toEqual([ + "pumps-symbol", + "pumps-hover", + "pumps-selected-outer", + "pumps-selected", + "pumps-hit" + ]); }); }); diff --git a/src/features/workbench/map/map-control-config.ts b/src/features/workbench/map/map-control-config.ts index c46f386..7c325da 100644 --- a/src/features/workbench/map/map-control-config.ts +++ b/src/features/workbench/map/map-control-config.ts @@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries( layer.id, { label: layer.label, - description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}` + description: layer.id === "scada" + ? "后端业务 API" + : `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}` } ]) ) as Record; @@ -48,6 +50,8 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [ { id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" }, { id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }, { id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir }, + { id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }, + { id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }, { id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow }, { id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure }, { id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" } diff --git a/src/features/workbench/map/map-feature-query.test.ts b/src/features/workbench/map/map-feature-query.test.ts index 66ab280..1274a2f 100644 --- a/src/features/workbench/map/map-feature-query.test.ts +++ b/src/features/workbench/map/map-feature-query.test.ts @@ -15,7 +15,6 @@ describe("map feature WFS query", () => { junctions: "id", valves: "id", reservoirs: "id", - scada: "id", pumps: "id", tanks: "id" }); @@ -23,18 +22,21 @@ describe("map feature WFS query", () => { .toBe("\"id\" IN ('J-1')"); }); - it("escapes CQL literals and uses materialized WFS layers", () => { + it("escapes CQL literals and uses the new materialized WFS layers", () => { expect(escapeCqlLiteral("a'b")).toBe("'a''b'"); const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] }); - expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat"); + expect(url.searchParams.get("typeNames")).toContain("tjwater_next:pipes"); expect(url.searchParams.get("srsName")).toBe("EPSG:4326"); expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')"); }); - it("uses the authoritative supply asset WFS layers", () => { - expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves"); - expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs"); - expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada"); + it("uses WFS only for authoritative supply asset layers", () => { + expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:valves"); + expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs"); + expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({ + ok: false, + code: "UNSUPPORTED_SOURCE" + }); }); it("limits ids and permits full-network requests only for pipes", () => { @@ -42,8 +44,8 @@ describe("map feature WFS query", () => { expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES)); expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 }); expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 }); - expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 }); - expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 }); + expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } }); + expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } }); expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) })) .toMatchObject({ ok: false, status: 400 }); expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] })) diff --git a/src/features/workbench/map/map-feature-query.ts b/src/features/workbench/map/map-feature-query.ts index 9f84d36..b87ee12 100644 --- a/src/features/workbench/map/map-feature-query.ts +++ b/src/features/workbench/map/map-feature-query.ts @@ -2,16 +2,15 @@ import type { FeatureCollection } from "geojson"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config"; import { SOURCE_LAYERS, - isAvailableWaterNetworkSourceId, - type WaterNetworkSourceId + GEOSERVER_WATER_NETWORK_SOURCE_IDS, + type GeoServerWaterNetworkSourceId } from "./sources"; -export const MAP_FEATURE_ID_FIELDS: Record = { +export const MAP_FEATURE_ID_FIELDS: Record = { pipes: "id", junctions: "id", valves: "id", reservoirs: "id", - scada: "id", pumps: "id", tanks: "id" }; @@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128; export const MAX_PIPE_FEATURES = 5000; export type MapFeatureQuery = { - sourceId: WaterNetworkSourceId; + sourceId: GeoServerWaterNetworkSourceId; featureIds?: string[]; }; @@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation { } const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown }; - if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) { + if ( + typeof sourceId !== "string" || + !(GEOSERVER_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(sourceId) + ) { return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 }; } @@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation { return { ok: true, - value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) } + value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) } }; } diff --git a/src/features/workbench/map/scada-analysis.test.ts b/src/features/workbench/map/scada-analysis.test.ts index a64987b..5d6901f 100644 --- a/src/features/workbench/map/scada-analysis.test.ts +++ b/src/features/workbench/map/scada-analysis.test.ts @@ -56,19 +56,19 @@ describe("SCADA analysis overlay", () => { }); it("validates one to one hundred unique IDs and fixed levels", () => { - expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([ - { sensor_id: "MP01", level: "high" } + expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([ + { device_id: "MP01", level: "high" } ]); expect(parseScadaAnalysisItems([])).toBeNull(); - expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull(); - expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull(); + expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull(); + expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "critical" }])).toBeNull(); }); - it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => { + it("uses trusted API device IDs for localized labels and reports partial misses", () => { const result = createScadaAnalysisCollection(scadaCollection, [ - { sensor_id: "MP01", level: "high" }, - { sensor_id: "MP02", level: "medium" }, - { sensor_id: "MP404", level: "low" } + { device_id: "MP01", level: "high" }, + { device_id: "MP02", level: "medium" }, + { device_id: "MP404", level: "low" } ]); expect(result.result).toEqual({ rendered_ids: ["MP01", "MP02"], @@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => { level_counts: { high: 1, medium: 1, low: 0, unrated: 0 } }); expect(result.collection.features.map((feature) => feature.properties)).toEqual([ - expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }), - expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 }) + expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }), + expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 }) ]); }); }); @@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => { const scadaCollection: FeatureCollection = { type: "FeatureCollection", features: [ - { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } }, - { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } } + { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } }, + { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } } ] }; diff --git a/src/features/workbench/map/scada-analysis.ts b/src/features/workbench/map/scada-analysis.ts index 35cff40..c26e77d 100644 --- a/src/features/workbench/map/scada-analysis.ts +++ b/src/features/workbench/map/scada-analysis.ts @@ -12,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = { export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const; export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number]; -export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel }; +export type ScadaAnalysisItem = { device_id: string; level: ScadaAnalysisLevel }; export type ScadaAnalysisLevelCounts = Record; export type ScadaAnalysisRenderResult = { rendered_ids: string[]; @@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n for (const entry of value) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; const keys = Object.keys(entry); - if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null; - const { sensor_id: rawSensorId, level } = entry as Record; - const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : ""; + if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null; + const { device_id: rawDeviceId, level } = entry as Record; + const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : ""; if ( - !sensorId || - sensorId.length > 128 || + !deviceId || + deviceId.length > 128 || !SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) || - seen.has(sensorId) + seen.has(deviceId) ) return null; - seen.add(sensorId); - items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel }); + seen.add(deviceId); + items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel }); } return items; } @@ -164,13 +164,13 @@ export function createScadaAnalysisCollection( collection: FeatureCollection, items: ScadaAnalysisItem[] ): { collection: FeatureCollection; result: Omit } { - const featuresBySensorId = new Map>(); + const featuresByDeviceId = new Map>(); collection.features.forEach((feature) => { - const sensorId = typeof feature.properties?.sensor_id === "string" - ? feature.properties.sensor_id.trim() + const deviceId = typeof feature.properties?.device_id === "string" + ? feature.properties.device_id.trim() : ""; - if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) { - featuresBySensorId.set(sensorId, feature as Feature); + if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) { + featuresByDeviceId.set(deviceId, feature as Feature); } }); @@ -179,26 +179,26 @@ export function createScadaAnalysisCollection( const missingIds: string[] = []; const features: Array> = []; items.forEach((item) => { - const feature = featuresBySensorId.get(item.sensor_id); + const feature = featuresByDeviceId.get(item.device_id); if (!feature) { - missingIds.push(item.sensor_id); + missingIds.push(item.device_id); return; } - const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim(); - if (!trustedSensorId) { - missingIds.push(item.sensor_id); + const trustedDeviceId = String(feature.properties?.device_id ?? "").trim(); + if (!trustedDeviceId) { + missingIds.push(item.device_id); return; } - renderedIds.push(trustedSensorId); + renderedIds.push(trustedDeviceId); levelCounts[item.level] += 1; features.push({ ...feature, properties: { ...feature.properties, - sensor_id: trustedSensorId, + device_id: trustedDeviceId, analysis_level: item.level, analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level], - analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`, + analysis_label: `${trustedDeviceId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`, sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level] } }); diff --git a/src/features/workbench/map/scada.test.ts b/src/features/workbench/map/scada.test.ts index faae4cf..3c7b957 100644 --- a/src/features/workbench/map/scada.test.ts +++ b/src/features/workbench/map/scada.test.ts @@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals"; describe("SCADA map presentation", () => { it("maps the authoritative device types and falls back to unknown", () => { expect(SCADA_ICON_EXPRESSION).toEqual([ - "match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], + "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]], "pipe_flow", SCADA_IMAGE_IDS.flow, "pressure", SCADA_IMAGE_IDS.pressure, SCADA_IMAGE_IDS.pressure @@ -28,6 +28,13 @@ describe("SCADA map presentation", () => { expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID); }); + it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => { + [...scadaLayers, ...scadaFallbackLayers].forEach((layer) => { + expect("source" in layer ? layer.source : undefined).toBe("scada"); + expect(layer).not.toHaveProperty("source-layer"); + }); + }); + it("keeps the icon and selected ring consistent with junction selection", () => { const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol"); const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer"); diff --git a/src/features/workbench/map/scada.ts b/src/features/workbench/map/scada.ts index 855c984..b4ad671 100644 --- a/src/features/workbench/map/scada.ts +++ b/src/features/workbench/map/scada.ts @@ -8,7 +8,6 @@ import { } from "./supply-style-expressions"; export const SCADA_SOURCE_ID = "scada"; -export const SCADA_SOURCE_LAYER = "geo_scada"; export const SCADA_HIT_LAYER_ID = "scada-hit"; export const SCADA_ICON_PATHS = { @@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = { } as const; export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ - "match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], + "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]], "pipe_flow", SCADA_IMAGE_IDS.flow, "pressure", SCADA_IMAGE_IDS.pressure, SCADA_IMAGE_IDS.pressure @@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => ]; const scadaColor: ExpressionSpecification = [ - "match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], + "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]], "pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow, MAP_STYLE_TOKENS.supply.scadaPressure ]; @@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = { }; const scadaCasingLayer: Layer = { - id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, + id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius, "circle-color": MAP_STYLE_TOKENS.canvas.casing, @@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = { }; const scadaFillLayer: Layer = { - id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, + id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.radius, "circle-color": createSupplyStateColor(scadaColor), @@ -71,13 +70,13 @@ const scadaFillLayer: Layer = { }; const scadaSymbolLayer: Layer = { - id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, + id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true }, paint: { "icon-opacity": supplyIconOpacityExpression } }; const scadaFallbackLayer: Layer = { - id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, + id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2], "circle-color": MAP_STYLE_TOKENS.canvas.casing, @@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = { }; export const scadaInteractionLayers: Layer[] = [ - { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, - { id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, - { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } } + { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, + { id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, + { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } } ]; const scadaHitLayer: Layer = { - id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, + id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 } }; diff --git a/src/features/workbench/map/sources.test.ts b/src/features/workbench/map/sources.test.ts index 4550b7a..65d6cc4 100644 --- a/src/features/workbench/map/sources.test.ts +++ b/src/features/workbench/map/sources.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, + GEOSERVER_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, + createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS, WATER_NETWORK_SOURCE_IDS @@ -10,13 +12,12 @@ import { describe("createWaterNetworkSources", () => { it("catalogs published and pre-registered supply system layers", () => { expect(SOURCE_LAYERS).toEqual({ - pipes: "geo_pipes_mat", - junctions: "geo_junctions_mat", - valves: "geo_valves", - reservoirs: "geo_reservoirs", - scada: "geo_scada", - pumps: "geo_pumps", - tanks: "geo_tanks" + pipes: "pipes", + junctions: "junctions", + valves: "valves", + reservoirs: "reservoirs", + pumps: "pumps", + tanks: "tanks" }); expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]); expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([ @@ -25,31 +26,45 @@ describe("createWaterNetworkSources", () => { ["valves", true], ["reservoirs", true], ["scada", true], - ["pumps", false], - ["tanks", false] + ["pumps", true], + ["tanks", true] ]); expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({ geometry: "point", - sourceLayer: "geo_valves" + sourceLayer: "valves" }); }); it("loads only available supply system layers from GeoServer WMTS", () => { - expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]); + expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]); expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({ label: "水库", icon: "reservoir" }); const sources = createWaterNetworkSources(); - expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS); - AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => { + expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS); + GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => { expect(sources[sourceId].tiles).toEqual([ - expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`) + expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`) ]); expect(sources[sourceId].promoteId).toBe("id"); + expect(sources[sourceId].bounds).toEqual([ + 121.35162408429075, + 30.81049932304578, + 121.77248479490075, + 31.007207809222017 + ]); + }); + expect("pumps" in sources).toBe(true); + expect("tanks" in sources).toBe(true); + expect("scada" in sources).toBe(false); + }); + + it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => { + expect(createBaseStyle().sources.scada).toMatchObject({ + type: "geojson", + promoteId: "device_id" }); - expect("pumps" in sources).toBe(false); - expect("tanks" in sources).toBe(false); }); }); diff --git a/src/features/workbench/map/sources.ts b/src/features/workbench/map/sources.ts index 3143399..18b9555 100644 --- a/src/features/workbench/map/sources.ts +++ b/src/features/workbench/map/sources.ts @@ -6,19 +6,20 @@ import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis"; +import { SCADA_SOURCE_ID } from "./scada"; export const WATER_NETWORK_GLOBAL_VIEW = { - bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742] + bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742], + bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017] } as const; export const SOURCE_LAYERS = { - pipes: "geo_pipes_mat", - junctions: "geo_junctions_mat", - valves: "geo_valves", - reservoirs: "geo_reservoirs", - scada: "geo_scada", - pumps: "geo_pumps", - tanks: "geo_tanks" + pipes: "pipes", + junctions: "junctions", + valves: "valves", + reservoirs: "reservoirs", + pumps: "pumps", + tanks: "tanks" } as const; export const WATER_NETWORK_SOURCE_IDS = [ @@ -35,7 +36,7 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number]; export type SupplyLayerCatalogItem = { id: WaterNetworkSourceId; - sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId]; + sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]; geometry: "line" | "point"; available: boolean; label: string; @@ -77,7 +78,7 @@ export const SUPPLY_LAYER_CATALOG = [ }, { id: "scada", - sourceLayer: SOURCE_LAYERS.scada, + sourceLayer: undefined, geometry: "point", available: true, label: "SCADA", @@ -87,7 +88,7 @@ export const SUPPLY_LAYER_CATALOG = [ id: "pumps", sourceLayer: SOURCE_LAYERS.pumps, geometry: "point", - available: false, + available: true, label: "水泵", icon: "pump" }, @@ -95,7 +96,7 @@ export const SUPPLY_LAYER_CATALOG = [ id: "tanks", sourceLayer: SOURCE_LAYERS.tanks, geometry: "point", - available: false, + available: true, label: "水箱", icon: "tank" } @@ -103,12 +104,14 @@ export const SUPPLY_LAYER_CATALOG = [ export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter( (layer) => layer.available -).map((layer) => layer.id) as Extract< - WaterNetworkSourceId, - "pipes" | "junctions" | "valves" | "reservoirs" | "scada" ->[]; +).map((layer) => layer.id) as WaterNetworkSourceId[]; export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number]; +export type GeoServerWaterNetworkSourceId = Exclude; + +export const GEOSERVER_WATER_NETWORK_SOURCE_IDS = AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter( + (sourceId): sourceId is GeoServerWaterNetworkSourceId => sourceId !== "scada" +); export function isAvailableWaterNetworkSourceId( value: string @@ -118,11 +121,12 @@ export function isAvailableWaterNetworkSourceId( export function createWaterNetworkSources() { return Object.fromEntries( - SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [ - layer.id, - createGeoServerVectorSource(layer.sourceLayer) - ]) - ) as Record; + SUPPLY_LAYER_CATALOG.flatMap((layer) => + layer.available && layer.sourceLayer !== undefined + ? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const] + : [] + ) + ) as Record; } function createGeoServerVectorSource( @@ -135,12 +139,18 @@ function createGeoServerVectorSource( `${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile` ], minzoom: 0, - maxzoom: 24 + maxzoom: 24, + bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326] }; } export function createBaseStyle(mapboxToken?: string): StyleSpecification { const sources: StyleSpecification["sources"] = { + [SCADA_SOURCE_ID]: { + type: "geojson", + promoteId: "device_id", + data: { type: "FeatureCollection", features: [] } + }, [SCADA_ANALYSIS_SOURCE_ID]: { type: "geojson", data: createEmptyScadaAnalysisCollection() diff --git a/src/features/workbench/map/workbench-map-controller.test.ts b/src/features/workbench/map/workbench-map-controller.test.ts index 73b7d9d..7c54a81 100644 --- a/src/features/workbench/map/workbench-map-controller.test.ts +++ b/src/features/workbench/map/workbench-map-controller.test.ts @@ -56,7 +56,7 @@ describe("WorkbenchMapController", () => { await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); - expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows"); + expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows"); expect(requestUrl.searchParams.get("request")).toBe("GetFeature"); expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')"); expect(map.setFeatureState).toHaveBeenCalled(); @@ -81,7 +81,7 @@ describe("WorkbenchMapController", () => { expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ source: "pipes", - "source-layer": "geo_pipes_mat" + "source-layer": "pipes" }); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); vi.unstubAllGlobals(); @@ -96,7 +96,7 @@ describe("WorkbenchMapController", () => { expect(map.setFeatureState).toHaveBeenCalledWith( expect.objectContaining({ source: "pipes", - sourceLayer: "geo_pipes_mat", + sourceLayer: "pipes", id: "P-1" }), { flowDirection: -1 } @@ -251,13 +251,13 @@ describe("WorkbenchMapController", () => { { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, - properties: { sensor_id: "MP01" } + properties: { device_id: "MP01" } } ] }); await expect( - controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]) + controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }]) ).resolves.toMatchObject({ rendered_ids: ["MP01"], fitted: true diff --git a/src/features/workbench/map/workbench-map-controller.ts b/src/features/workbench/map/workbench-map-controller.ts index ee31837..fb11d9b 100644 --- a/src/features/workbench/map/workbench-map-controller.ts +++ b/src/features/workbench/map/workbench-map-controller.ts @@ -47,6 +47,7 @@ export type WorkbenchMapErrorCode = | "MAP_NOT_READY" | "FEATURE_NOT_FOUND" | "WFS_UNAVAILABLE" + | "SCADA_API_UNAVAILABLE" | "SCADA_FEATURES_NOT_FOUND" | "ACTION_SUPERSEDED" | "FLOW_UNAVAILABLE" @@ -88,6 +89,7 @@ export type WorkbenchMapControllerOptions = { isReady: () => boolean; getPadding: () => PaddingOptions; fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise; + getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise; reducedMotion?: () => boolean; }; @@ -320,16 +322,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands { try { sourceCollection = await this.fetchFeatures( "scada", - items.map((item) => item.sensor_id), + items.map((item) => item.device_id), signal ); } catch { if (revision === this.scadaAnalysisRevision) { - this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); + this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" }); } throw new Error( revision === this.scadaAnalysisRevision - ? "WFS_UNAVAILABLE" + ? "SCADA_API_UNAVAILABLE" : "ACTION_SUPERSEDED" ); } @@ -463,13 +465,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands { this.patchState({ pending: false }); return feature; } catch { - this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); + this.patchState({ + pending: false, + errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE" + }); return null; } } private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) { if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal); + if (sourceId === "scada") { + if (!this.options.getScadaFeatures) throw new Error("SCADA_API_UNAVAILABLE"); + return this.options.getScadaFeatures(featureIds, signal); + } const parsed = parseMapFeatureQuery({ sourceId, featureIds }); if (!parsed.ok) throw new Error(parsed.code); const response = await fetch(createMapFeatureWfsUrl(parsed.value), { @@ -516,7 +525,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary { } function toFeatureStateTarget(target: FeatureTarget) { - return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId }; + return target.sourceId === "scada" + ? { source: target.sourceId, id: target.featureId } + : { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId }; } function collectCoordinates(geometry: Geometry | null): Position[] { diff --git a/src/features/workbench/utils/feature-properties.test.ts b/src/features/workbench/utils/feature-properties.test.ts index 8786a73..8e4d43a 100644 --- a/src/features/workbench/utils/feature-properties.test.ts +++ b/src/features/workbench/utils/feature-properties.test.ts @@ -8,8 +8,8 @@ import { describe("feature panel properties", () => { it.each([ - ["pipes", ["id", "diameter", "length", "material"]], - ["junctions", ["id", "elevation", "demand", "pressure"]] + ["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]], + ["junctions", ["id", "elevation", "base_demand"]] ] as const)("shows the configured %s fields", (layer, expectedKeys) => { const entries = getFeaturePanelProperties(layer, {}, "feature-id"); @@ -20,16 +20,22 @@ describe("feature panel properties", () => { it("formats pipe diameter from GeoServer DN millimeter values", () => { const entries = getFeaturePanelProperties("pipes", { id: "P-1", + start_node_id: "J-1", + end_node_id: "J-2", diameter: 600, length: 42.5, - material: "DI" + roughness: 120, + status: "OPEN" }); expect(entries.map(({ label, value }) => [label, value])).toEqual([ ["编号", "P-1"], + ["起点节点", "J-1"], + ["终点节点", "J-2"], ["管径", "600 mm"], ["长度", "42.50 m"], - ["材质", "球墨铸铁"] + ["粗糙系数", "120"], + ["状态", "开启"] ]); }); @@ -37,14 +43,14 @@ describe("feature panel properties", () => { const metrics = getFeatureInsightMetrics("pipes", { diameter: 600, length: 42.5, - material: "DI", + roughness: 120, status: "OPEN" }); expect(metrics).toEqual([ { label: "管径", value: "600 mm" }, { label: "长度", value: "42.50 m" }, - { label: "材质", value: "球墨铸铁" }, + { label: "粗糙系数", value: "120" }, { label: "状态", value: "开启" } ]); }); @@ -52,56 +58,62 @@ describe("feature panel properties", () => { it("localizes common enumerated property values in the panel", () => { expect(getFeaturePanelProperties("valves", { id: "V-1", - node1: "J-1", - node2: "J-2", + start_node_id: "J-1", + end_node_id: "J-2", diameter: 300, - v_type: "PRV", - setting: "OPEN" + valve_type: "PRV", + setting: "OPEN", + minor_loss: 0.2 }).map(({ label, value }) => [label, value])).toEqual([ ["编号", "V-1"], ["起点节点", "J-1"], ["终点节点", "J-2"], ["管径", "300 mm"], ["阀门类型", "减压阀"], - ["阀门设定", "开启"] + ["阀门设定", "开启"], + ["局部损失系数", "0.20"] ]); expect(getFeaturePanelProperties("scada", { - id: "S-1", - type: "pressure", - associated_element_id: "J-1", + device_id: "S-1", + device_type: "pressure", + node_id: "J-1", + link_id: null, + api_query_id: "query-1", transmission_mode: "non_realtime", transmission_frequency: "15min", reliability: "high" }).map(({ label, value }) => [label, value])).toEqual([ - ["编号", "S-1"], - ["类型", "压力监测"], - ["关联资产", "J-1"], + ["设备 ID", "S-1"], + ["设备类型", "压力监测"], + ["关联节点", "J-1"], + ["关联连线", "暂无"], + ["采集接口", "query-1"], ["传输方式", "非实时"], ["传输频率", "15min"], ["可靠性", "高"] ]); expect(getFeaturePanelProperties("scada", { - id: "S-2", - type: "pipe_flow" - }).find((entry) => entry.key === "type")?.value).toBe("流量监测"); + device_id: "S-2", + device_type: "pipe_flow" + }).find((entry) => entry.key === "device_type")?.value).toBe("流量监测"); }); it("uses the map feature id when the properties omit it", () => { - const model = getFeaturePanelModel("junctions", { demand: 3.4 }, "map-feature-id"); + const model = getFeaturePanelModel("junctions", { base_demand: 3.4 }, "map-feature-id"); expect(model.id).toBe("map-feature-id"); expect(model.badge).toBeUndefined(); - expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "demand", "pressure"]); + expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "base_demand"]); }); it.each([ - ["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]], - ["reservoirs", ["id", "head", "pattern"]], - ["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]], - ["pumps", ["id", "node1", "node2", "status"]], - ["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]] + ["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]], + ["reservoirs", ["id", "head", "pattern_id"]], + ["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]], + ["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]], + ["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]] ] as const)("localizes configured %s panel field labels", (layer, keys) => { const entries = getFeaturePanelProperties(layer, {}, "feature-id"); @@ -112,7 +124,7 @@ describe("feature panel properties", () => { label: getFeaturePropertyLabel(entry.key) })) ); - expect(entries.filter((entry) => entry.key !== "id").map((entry) => entry.label)) + expect(entries.filter((entry) => entry.key !== "id" && entry.key !== "device_id").map((entry) => entry.label)) .not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/)); }); }); diff --git a/src/features/workbench/utils/feature-properties.ts b/src/features/workbench/utils/feature-properties.ts index b90f75c..eb63a11 100644 --- a/src/features/workbench/utils/feature-properties.ts +++ b/src/features/workbench/utils/feature-properties.ts @@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record = { fid: "GeoServer 要素 ID", id: "编号", scada_id: "编号 UUID", - sensor_id: "传感器 ID", - sensor_name: "传感器名称", swmm_node: "SWMM 节点", topology_order: "拓扑序", distance_to_wwtp_m: "距处理厂距离", @@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record = { name: "名称", code: "编码", type: "类型", + device_type: "设备类型", node1: "起点节点", node2: "终点节点", + start_node_id: "起点节点", + end_node_id: "终点节点", from_node: "起点节点", to_node: "终点节点", diameter: "管径", @@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record = { max_depth: "最大深度", invert_elevation: "井底高程", v_type: "阀门类型", + valve_type: "阀门类型", setting: "阀门设定", minor_loss: "局部损失系数", orifice_type: "孔口类型", @@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record = { status: "状态", head: "水头", pattern: "模式", + pattern_id: "模式", + head_curve_id: "扬程曲线", + volume_curve_id: "容积曲线", init_level: "初始水位", min_level: "最低水位", max_level: "最高水位", + initial_level: "初始水位", + minimum_level: "最低水位", + maximum_level: "最高水位", + minimum_volume: "最小容积", + overflow: "允许溢流", + power: "功率", + speed: "转速倍率", material: "材质", elevation: "高程", demand: "需水量", + base_demand: "基础需水量", + demands: "需水配置", pressure: "压力", flow: "流量", velocity: "流速", @@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record = { point_external_id: "测点编号", point_name: "测点名称", associated_element_id: "关联资产", + node_id: "关联节点", + link_id: "关联连线", + api_query_id: "采集接口", transmission_mode: "传输方式", transmission_frequency: "传输频率", reliability: "可靠性", @@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record = { fid: 9, id: 10, scada_id: 10, - sensor_id: 10, - sensor_name: 12, swmm_node: 13, topology_order: 14, distance_to_wwtp_m: 15, @@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record = { material: 23, node1: 24, from_node: 24, + start_node_id: 24, node2: 25, to_node: 25, + end_node_id: 25, elevation: 30, demand: 31, + base_demand: 31, pressure: 32, flow: 33, velocity: 34, @@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record = { }; const FEATURE_PANEL_PROPERTY_KEYS: Record = { - pipes: ["id", "diameter", "length", "material"], - junctions: ["id", "elevation", "demand", "pressure"], - valves: ["id", "node1", "node2", "diameter", "v_type", "setting"], - reservoirs: ["id", "head", "pattern"], - scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"], - pumps: ["id", "node1", "node2", "status"], - tanks: ["id", "elevation", "init_level", "min_level", "max_level"] + pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"], + junctions: ["id", "elevation", "base_demand"], + valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"], + reservoirs: ["id", "head", "pattern_id"], + scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"], + pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"], + tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"] }; const FEATURE_PANEL_BADGE_KEYS: Partial> = { @@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record { const propertyValue = getPropertyValue(properties, key); - const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue; + const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue; return { key, @@ -391,7 +411,7 @@ export function getFeaturePanelModel( featureId?: string ): FeaturePanelModel { const entries = getFeaturePanelProperties(layer, properties, featureId); - const idKey = "id"; + const idKey = layer === "scada" ? "device_id" : "id"; const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无"; const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer]; const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined; @@ -420,7 +440,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value; } - if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") { + if (["gated", "overflow"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") { return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value); } @@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { : formatValue(value); } - if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey) + if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m", "initial_level", "minimum_level", "maximum_level"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") { return `${formatValue(value)} m`; } @@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) { return STATUS_LABELS[normalizedValue]; } - if (normalizedKey === "v_type") { + if (normalizedKey === "v_type" || normalizedKey === "valve_type") { return VALVE_TYPE_LABELS[normalizedValue]; } @@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) { return MATERIAL_LABELS[normalizedValue]; } - if (normalizedKey === "type") { + if (normalizedKey === "type" || normalizedKey === "device_type") { return ASSET_TYPE_LABELS[normalizedValue]; } @@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) { return RELIABILITY_LABELS[normalizedValue]; } - if (normalizedKey === "pattern") { + if (normalizedKey === "pattern" || normalizedKey === "pattern_id") { return PATTERN_LABELS[normalizedValue]; } diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts index 1de0d17..2338619 100644 --- a/src/shared/config/env.test.ts +++ b/src/shared/config/env.test.ts @@ -12,6 +12,7 @@ describe("runtime frontend configuration", () => { TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_GEOSERVER_WORKSPACE: "project-a", + TJWATER_SERVER_API_BASE_URL: "https://server.example.test", TJWATER_AGENT_API_BASE_URL: "https://agent.example.test", TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_ENABLE_MSW: "false" @@ -23,6 +24,7 @@ describe("runtime frontend configuration", () => { TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_GEOSERVER_WORKSPACE: "project-a", + TJWATER_SERVER_API_BASE_URL: "https://server.example.test", TJWATER_AGENT_API_BASE_URL: "https://agent.example.test", TJWATER_ENABLE_DEV_PANEL: true, TJWATER_ENABLE_MSW: false diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index ac757f5..d0b5ac8 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -47,7 +47,8 @@ const runtimeConfigSchema = z TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"), TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""), TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"), - TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"), + TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"), + TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"), TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"), TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false), TJWATER_ENABLE_MSW: runtimeBoolean(false) diff --git a/tests/browser/button-system.e2e.ts b/tests/browser/button-system.e2e.ts index 832cf47..b8b87ec 100644 --- a/tests/browser/button-system.e2e.ts +++ b/tests/browser/button-system.e2e.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { mockScadaApi } from "./support/mock-scada-api"; test.beforeEach(async ({ page }) => { await prepareWorkbench(page); @@ -213,7 +214,8 @@ async function prepareWorkbench(page: Page) { TJWATER_AUTH_MODE: "disabled", TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAP_URL: "https://button-system.invalid/geoserver", - TJWATER_GEOSERVER_WORKSPACE: "tjwater", + TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", + TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "false", TJWATER_ENABLE_MSW: "false" @@ -229,4 +231,5 @@ async function prepareWorkbench(page: Page) { await page.route("**/*.riv", async (route) => { await route.abort(); }); + await mockScadaApi(page); } diff --git a/tests/browser/scada-api-source.e2e.ts b/tests/browser/scada-api-source.e2e.ts new file mode 100644 index 0000000..b195eca --- /dev/null +++ b/tests/browser/scada-api-source.e2e.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; +import { mockScadaApi } from "./support/mock-scada-api"; + +test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => { + await page.route("**/runtime-config.js", async (route) => { + await route.fulfill({ + contentType: "application/javascript", + body: `globalThis.__TJWATER_CONFIG__ = { + TJWATER_AUTH_MODE: "disabled", + TJWATER_MAPBOX_ACCESS_TOKEN: "", + TJWATER_MAP_URL: "https://scada-map.invalid/geoserver", + TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", + TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", + TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", + TJWATER_ENABLE_DEV_PANEL: "false", + TJWATER_ENABLE_MSW: "false" + };` + }); + }); + await page.route("https://scada-map.invalid/**", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + await mockScadaApi(page, [{ + device_id: "SCADA-1", + device_type: "pressure", + node_id: "J-1", + link_id: null, + api_query_id: "query-1", + transmission_mode: "realtime", + transmission_frequency: "2s", + reliability: 100, + x: 1, + y: 2, + longitude: 121.5, + latitude: 30.9 + }]); + const deviceRequestPromise = page.waitForRequest((request) => + request.url().includes("/api/v1/scada-devices") + ); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const deviceRequest = await deviceRequestPromise; + expect(deviceRequest.headers()["x-project-id"]).toBe("project-1"); + await expect.poll(async () => page.evaluate(() => { + const map = globalThis.__waterNetworkMap; + if (!map) return []; + return map.querySourceFeatures("scada").map((feature) => ({ + id: String(feature.id), + deviceId: String(feature.properties?.device_id) + })); + })).toContainEqual({ id: "SCADA-1", deviceId: "SCADA-1" }); +}); diff --git a/tests/browser/support/mock-scada-api.ts b/tests/browser/support/mock-scada-api.ts new file mode 100644 index 0000000..11946a7 --- /dev/null +++ b/tests/browser/support/mock-scada-api.ts @@ -0,0 +1,27 @@ +import type { Page } from "@playwright/test"; + +export async function mockScadaApi(page: Page, devices: unknown[] = []) { + await page.route("https://tjwater-api.invalid/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/v1/projects") { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }], + total: 1, + limit: 1000, + offset: 0 + }) + }); + return; + } + if (url.pathname === "/api/v1/scada-devices") { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ items: devices, total: devices.length, limit: 1000, offset: 0 }) + }); + return; + } + await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); + }); +} diff --git a/tests/browser/workbench-visual.e2e.ts b/tests/browser/workbench-visual.e2e.ts index 7284f5e..40a3c80 100644 --- a/tests/browser/workbench-visual.e2e.ts +++ b/tests/browser/workbench-visual.e2e.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; +import { mockScadaApi } from "./support/mock-scada-api"; const EMPTY_RASTER_TILE = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", @@ -120,7 +121,8 @@ async function prepareDeterministicWorkbench(page: Page) { TJWATER_AUTH_MODE: "disabled", TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token", TJWATER_MAP_URL: "https://visual-map.invalid/geoserver", - TJWATER_GEOSERVER_WORKSPACE: "tjwater", + TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", + TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "false", TJWATER_ENABLE_MSW: "false" @@ -142,6 +144,7 @@ async function prepareDeterministicWorkbench(page: Page) { await page.route("**/*.riv", async (route) => { await route.abort(); }); + await mockScadaApi(page); } async function openWorkbench(page: Page) { diff --git a/tests/browser/workspace-main-frame.e2e.ts b/tests/browser/workspace-main-frame.e2e.ts index ee23030..ab30339 100644 --- a/tests/browser/workspace-main-frame.e2e.ts +++ b/tests/browser/workspace-main-frame.e2e.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; +import { mockScadaApi } from "./support/mock-scada-api"; test("the default workspace docks Agent beside the persistent map", async ({ page }) => { await page.setViewportSize({ width: 1600, height: 900 }); @@ -138,7 +139,8 @@ async function prepareMainFrame(page: Page) { TJWATER_AUTH_MODE: "disabled", TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver", - TJWATER_GEOSERVER_WORKSPACE: "tjwater", + TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", + TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_ENABLE_MSW: "false" @@ -148,4 +150,5 @@ async function prepareMainFrame(page: Page) { await page.route("https://workspace-map.invalid/**", async (route) => { await route.fulfill({ status: 204 }); }); + await mockScadaApi(page); } diff --git a/vite.config.ts b/vite.config.ts index 86fce2d..7672625 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,8 @@ function renderRuntimeConfig(values: Record) { TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater", TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "", TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver", - TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater", + 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_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false", TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"