Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf44cc0f25 | ||
|
|
31acd33a2e | ||
|
|
cf699d6207 | ||
|
|
f9c71cc076 | ||
|
|
28287a1447 | ||
|
|
abef9ffabf | ||
|
|
db696eb4f1 | ||
|
|
229cb0def2 | ||
|
|
1fb950806a | ||
|
|
073832034a | ||
|
|
57c494e8bd | ||
|
|
4a589d7a43 | ||
|
|
d39e58196f | ||
|
|
b93999646c | ||
|
|
b91be09f5c | ||
|
|
602b39d60a | ||
|
|
5e7101bfee | ||
|
|
21dbfd82b6 | ||
|
|
760370c93f | ||
|
|
3db95c27b5 | ||
|
|
45f1ebee7e | ||
|
|
d2f2895c36 | ||
|
|
bfadc95f93 | ||
|
|
be7b4149f2 | ||
|
|
1068304da5 | ||
|
|
f58e0a70f4 | ||
|
|
d08cf2abc1 | ||
|
|
bdd5eff776 |
+5
-1
@@ -1,6 +1,10 @@
|
|||||||
|
TJWATER_AUTH_MODE=required
|
||||||
|
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
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_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||||
TJWATER_ENABLE_DEV_PANEL=false
|
TJWATER_ENABLE_DEV_PANEL=false
|
||||||
TJWATER_ENABLE_MSW=false
|
TJWATER_ENABLE_MSW=false
|
||||||
|
|||||||
@@ -34,16 +34,24 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||||
|
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||||
|
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
| `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_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
||||||
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
||||||
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
||||||
|
|
||||||
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
||||||
|
|
||||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
||||||
|
|
||||||
|
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||||
|
|
||||||
|
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,24 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
runtime_config=$(jq -cn \
|
runtime_config=$(jq -cn \
|
||||||
|
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
|
||||||
|
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||||
|
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
--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 agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
|
||||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||||
'{
|
'{
|
||||||
|
TJWATER_AUTH_MODE: $authMode,
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||||
TJWATER_MAP_URL: $mapUrl,
|
TJWATER_MAP_URL: $mapUrl,
|
||||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||||
|
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
|
||||||
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
||||||
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
||||||
TJWATER_ENABLE_MSW: $enableMsw
|
TJWATER_ENABLE_MSW: $enableMsw
|
||||||
|
|||||||
@@ -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-*` 类。
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"edge-tts-ts": "^1.0.0",
|
"edge-tts-ts": "^1.0.0",
|
||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
|
"keycloak-js": "26.2.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
|
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
|
||||||
|
const runningInCi = Boolean(process.env.CI);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: ".",
|
testDir: ".",
|
||||||
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
reporter: "html",
|
forbidOnly: runningInCi,
|
||||||
|
retries: runningInCi ? 1 : 0,
|
||||||
|
workers: runningInCi ? 2 : undefined,
|
||||||
|
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
locale: "zh-CN",
|
locale: "zh-CN",
|
||||||
@@ -16,7 +20,7 @@ export default defineConfig({
|
|||||||
trace: "on-first-retry"
|
trace: "on-first-retry"
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: false
|
reuseExistingServer: false
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -83,6 +83,9 @@ importers:
|
|||||||
katex:
|
katex:
|
||||||
specifier: ^0.17.0
|
specifier: ^0.17.0
|
||||||
version: 0.17.0
|
version: 0.17.0
|
||||||
|
keycloak-js:
|
||||||
|
specifier: 26.2.4
|
||||||
|
version: 26.2.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@19.2.7)
|
version: 0.468.0(react@19.2.7)
|
||||||
@@ -2859,6 +2862,9 @@ packages:
|
|||||||
kdbush@4.1.0:
|
kdbush@4.1.0:
|
||||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||||
|
|
||||||
|
keycloak-js@26.2.4:
|
||||||
|
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -6645,6 +6651,8 @@ snapshots:
|
|||||||
|
|
||||||
kdbush@4.1.0: {}
|
kdbush@4.1.0: {}
|
||||||
|
|
||||||
|
keycloak-js@26.2.4: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
globalThis.__TJWATER_CONFIG__ = {};
|
globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000"
|
||||||
|
};
|
||||||
|
|||||||
+15
-6
@@ -17,6 +17,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
|||||||
expect(runtimeConfig).toMatchObject({
|
expect(runtimeConfig).toMatchObject({
|
||||||
TJWATER_MAP_URL: expect.any(String),
|
TJWATER_MAP_URL: expect.any(String),
|
||||||
TJWATER_GEOSERVER_WORKSPACE: 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)
|
TJWATER_AGENT_API_BASE_URL: expect.any(String)
|
||||||
});
|
});
|
||||||
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||||
@@ -100,7 +101,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
|||||||
expect(hoverBackground).not.toBe(restingBackground);
|
expect(hoverBackground).not.toBe(restingBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => {
|
test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -112,10 +113,18 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as
|
|||||||
expect(menuTransformOrigin).not.toBe("50% 50%");
|
expect(menuTransformOrigin).not.toBe("50% 50%");
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const commandShadow = await agentCommandControl.evaluate(
|
const navigationMaterial = await navigation.evaluate(
|
||||||
(element) => window.getComputedStyle(element).boxShadow
|
(element) => {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
backdropFilter: style.backdropFilter,
|
||||||
|
backgroundColor: style.backgroundColor
|
||||||
|
};
|
||||||
|
}
|
||||||
);
|
);
|
||||||
expect(commandShadow).toBe("none");
|
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||||
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
|
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||||
|
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-2
@@ -1,11 +1,16 @@
|
|||||||
import { MapToaster } from "@/features/map/core";
|
import { MapToaster } from "@/features/map/core";
|
||||||
import { MapWorkbenchPage } from "@/features/workbench";
|
import { MapWorkbenchPage } from "@/features/workbench";
|
||||||
|
import type { Authentication } from "@/shared/auth/keycloak-auth";
|
||||||
import { AppProviders } from "./providers";
|
import { AppProviders } from "./providers";
|
||||||
|
|
||||||
export function App() {
|
export function App({ authentication }: { authentication: Authentication }) {
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<MapWorkbenchPage />
|
<MapWorkbenchPage
|
||||||
|
user={authentication.user ?? undefined}
|
||||||
|
onLogout={authentication.logout}
|
||||||
|
getAccessToken={authentication.getAccessToken}
|
||||||
|
/>
|
||||||
<MapToaster />
|
<MapToaster />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Droplets, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
|
||||||
|
export function AuthStartupScreen({ error }: { error?: boolean }) {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
|
||||||
|
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
|
||||||
|
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
|
||||||
|
<Droplets size={22} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-4 text-lg font-semibold">
|
||||||
|
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<Button className="mt-5" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCw size={15} aria-hidden="true" />
|
||||||
|
重新连接
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||||
|
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||||
|
|
||||||
|
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||||
|
|
||||||
|
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
if (!init) throw new Error("Expected Agent request init");
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||||
|
expect(headers.get("Content-Type")).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
it("streams session events from the backend SSE endpoint", async () => {
|
it("streams session events from the backend SSE endpoint", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
|
||||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||||
|
|
||||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
|||||||
abort: (sessionId: string) => Promise<void>;
|
abort: (sessionId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentApiClientOptions = {
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||||
|
|
||||||
const CHAT_PATH = "/api/v1/agent/chat";
|
const CHAT_PATH = "/api/v1/agent/chat";
|
||||||
|
|
||||||
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
|
export function createAgentApiClient(
|
||||||
|
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||||
|
options: AgentApiClientOptions = {}
|
||||||
|
): AgentApiClient {
|
||||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||||
let activeBaseUrl = candidates[0] ?? "";
|
let activeBaseUrl = candidates[0] ?? "";
|
||||||
|
const setActiveBaseUrl = (baseUrl: string) => {
|
||||||
|
activeBaseUrl = baseUrl;
|
||||||
|
};
|
||||||
|
const request = (path: string, init?: RequestInit) =>
|
||||||
|
fetchWithFallback(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
setActiveBaseUrl,
|
||||||
|
path,
|
||||||
|
init,
|
||||||
|
options.getAccessToken
|
||||||
|
);
|
||||||
|
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
||||||
|
const response = await request(path, init);
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getResponseErrorMessage(data, response.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async createSession() {
|
async createSession() {
|
||||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
return requestJson<AgentChatSession>("/session", {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/session", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listSessions() {
|
async listSessions() {
|
||||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
"/sessions"
|
|
||||||
);
|
|
||||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFrontendActionRegistry() {
|
async getFrontendActionRegistry() {
|
||||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
return requestJson<unknown>("/frontend-action-registry");
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitFrontendActionResult(sessionId, actionId, result) {
|
async submitFrontendActionResult(sessionId, actionId, result) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||||
body: JSON.stringify(result)
|
body: JSON.stringify(result)
|
||||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadSession(sessionId) {
|
async loadSession(sessionId) {
|
||||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const data = text ? JSON.parse(text) : null;
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async streamSession(sessionId, options) {
|
async streamSession(sessionId, options) {
|
||||||
const response = await fetchWithFallback(
|
const response = await request(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||||
{ signal: options.signal }
|
{ signal: options.signal }
|
||||||
);
|
);
|
||||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestJsonWithFallback<unknown>(
|
await requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteSession(sessionId) {
|
async deleteSession(sessionId) {
|
||||||
await requestJsonWithFallback<unknown>(
|
await requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}`,
|
`/session/${encodeURIComponent(sessionId)}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getModels() {
|
async getModels() {
|
||||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
const payload = await requestJson<unknown>("/models");
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/models");
|
|
||||||
return toModelsResponse(payload);
|
return toModelsResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUiRegistry() {
|
async getUiRegistry() {
|
||||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
return requestJson<unknown>("/ui-registry");
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/ui-registry");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveRenderRef(renderRef, sessionId) {
|
async resolveRenderRef(renderRef, sessionId) {
|
||||||
const params = new URLSearchParams({ session_id: sessionId });
|
const params = new URLSearchParams({ session_id: sessionId });
|
||||||
return requestJsonWithFallback<unknown>(
|
return requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async replyPermission(requestId, options) {
|
async replyPermission(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async replyQuestion(requestId, options) {
|
async replyQuestion(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async rejectQuestion(requestId, options) {
|
async rejectQuestion(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async abort(sessionId) {
|
async abort(sessionId) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>("/abort", {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/abort", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ session_id: sessionId })
|
body: JSON.stringify({ session_id: sessionId })
|
||||||
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJsonWithFallback<T>(
|
|
||||||
baseUrls: string[],
|
|
||||||
activeBaseUrl: string,
|
|
||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
|
||||||
path: string,
|
|
||||||
init?: RequestInit
|
|
||||||
) {
|
|
||||||
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
|
|
||||||
const text = await response.text();
|
|
||||||
const data = text ? JSON.parse(text) : null;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(getResponseErrorMessage(data, response.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithFallback(
|
async function fetchWithFallback(
|
||||||
baseUrls: string[],
|
baseUrls: string[],
|
||||||
activeBaseUrl: string,
|
activeBaseUrl: string,
|
||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
setActiveBaseUrl: (baseUrl: string) => void,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit
|
init?: RequestInit,
|
||||||
|
getAccessToken?: AccessTokenProvider
|
||||||
) {
|
) {
|
||||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
let lastResponse: Response | null = null;
|
let lastResponse: Response | null = null;
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
const requestInit = accessToken
|
||||||
|
? {
|
||||||
|
...init,
|
||||||
|
headers: withBearerToken(init?.headers, accessToken)
|
||||||
|
}
|
||||||
|
: init;
|
||||||
|
|
||||||
for (const baseUrl of orderedBaseUrls) {
|
for (const baseUrl of orderedBaseUrls) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setActiveBaseUrl(baseUrl);
|
setActiveBaseUrl(baseUrl);
|
||||||
return response;
|
return response;
|
||||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
|||||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
|
||||||
|
const headers = new Headers(headersInit);
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldFallbackOnHttpStatus(status: number) {
|
function shouldFallbackOnHttpStatus(status: number) {
|
||||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
|
|||||||
<aside
|
<aside
|
||||||
aria-label="Agent 折叠栏"
|
aria-label="Agent 折叠栏"
|
||||||
className={cn(
|
className={cn(
|
||||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
@@ -82,6 +83,13 @@ import type {
|
|||||||
|
|
||||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||||
|
|
||||||
|
export type AgentWorkspaceContextItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "info" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
type AgentCommandPanelProps = {
|
type AgentCommandPanelProps = {
|
||||||
presentation?: AgentCommandPanelPresentation;
|
presentation?: AgentCommandPanelPresentation;
|
||||||
collapsing?: boolean;
|
collapsing?: boolean;
|
||||||
@@ -99,6 +107,7 @@ type AgentCommandPanelProps = {
|
|||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
approvalMode?: AgentApprovalMode;
|
approvalMode?: AgentApprovalMode;
|
||||||
uiResults?: AgentUiResult[];
|
uiResults?: AgentUiResult[];
|
||||||
|
workspaceContext?: AgentWorkspaceContextItem[];
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onRefreshHistory?: () => Promise<void> | void;
|
onRefreshHistory?: () => Promise<void> | void;
|
||||||
onStartNewSession?: () => Promise<void> | void;
|
onStartNewSession?: () => Promise<void> | void;
|
||||||
@@ -150,6 +159,7 @@ export function AgentCommandPanel({
|
|||||||
selectedModel = "",
|
selectedModel = "",
|
||||||
approvalMode = "request",
|
approvalMode = "request",
|
||||||
uiResults = [],
|
uiResults = [],
|
||||||
|
workspaceContext = [],
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onRefreshHistory,
|
onRefreshHistory,
|
||||||
onStartNewSession,
|
onStartNewSession,
|
||||||
@@ -302,7 +312,7 @@ export function AgentCommandPanel({
|
|||||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||||
"agent-panel-shell",
|
"agent-panel-shell",
|
||||||
presentation === "desktop-dock"
|
presentation === "desktop-dock"
|
||||||
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
|
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
|
||||||
: desktopFloating
|
: desktopFloating
|
||||||
? "acrylic-panel overflow-visible rounded-2xl border"
|
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||||
@@ -423,6 +433,27 @@ export function AgentCommandPanel({
|
|||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{workspaceContext.length > 0 ? (
|
||||||
|
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
|
||||||
|
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
|
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
|
||||||
|
{workspaceContext.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-md px-2 py-1",
|
||||||
|
item.tone === "info" && "bg-blue-50 text-blue-700",
|
||||||
|
item.tone === "warning" && "bg-amber-50 text-amber-700",
|
||||||
|
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
|
||||||
|
)}
|
||||||
|
title={`${item.label}:${item.value}`}
|
||||||
|
>
|
||||||
|
{item.label} · <strong className="font-semibold">{item.value}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{floatingPresentation ? (
|
{floatingPresentation ? (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{hasConversation && !historyOpen ? (
|
{hasConversation && !historyOpen ? (
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createRequest(name: string) {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
name,
|
name,
|
||||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||||
issuedAt: Date.now(),
|
issuedAt: Date.now(),
|
||||||
expiresAt: Date.now() + 15_000
|
expiresAt: Date.now() + 15_000
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||||
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
|
export type {
|
||||||
|
AgentCommandPanelPresentation,
|
||||||
|
AgentWorkspaceContextItem
|
||||||
|
} from "./components/agent-command-panel";
|
||||||
export { AgentPersona } from "./components/agent-persona";
|
export { AgentPersona } from "./components/agent-persona";
|
||||||
export { createAgentApiClient } from "./api/client";
|
export { createAgentApiClient } from "./api/client";
|
||||||
export type {
|
export type {
|
||||||
AgentApiClient,
|
AgentApiClient,
|
||||||
|
AgentApiClientOptions,
|
||||||
AgentChatSessionSummary,
|
AgentChatSessionSummary,
|
||||||
AgentLoadedChatSession,
|
AgentLoadedChatSession,
|
||||||
AgentSessionStreamEvent
|
AgentSessionStreamEvent
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["P1", "P2"],
|
featureIds: ["P1", "P2"],
|
||||||
layer: "geo_pipes_mat",
|
layer: "pipes",
|
||||||
fallbackText: undefined
|
fallbackText: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["42"],
|
featureIds: ["42"],
|
||||||
layer: "geo_junctions_mat"
|
layer: "junctions"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["S-1"],
|
featureIds: ["S-1"],
|
||||||
layer: "geo_scada"
|
layer: "scada"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||||
locate_junctions: "geo_junctions_mat",
|
locate_junctions: "junctions",
|
||||||
locate_pipes: "geo_pipes_mat",
|
locate_pipes: "pipes",
|
||||||
locate_valves: "geo_valves",
|
locate_valves: "valves",
|
||||||
locate_reservoirs: "geo_reservoirs",
|
locate_reservoirs: "reservoirs",
|
||||||
locate_scada: "geo_scada",
|
locate_scada: "scada",
|
||||||
locate_pumps: "geo_pumps",
|
locate_pumps: "pumps",
|
||||||
locate_tanks: "geo_tanks"
|
locate_tanks: "tanks"
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
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_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
||||||
const ALLOWED_LOCATE_LAYERS = new Set([
|
const ALLOWED_LOCATE_LAYERS = new Set([
|
||||||
...SUPPLY_SOURCE_IDS,
|
...SUPPLY_SOURCE_IDS
|
||||||
"pumps",
|
|
||||||
"tanks",
|
|
||||||
"geo_junctions_mat",
|
|
||||||
"geo_pipes_mat",
|
|
||||||
"geo_valves",
|
|
||||||
"geo_reservoirs",
|
|
||||||
"geo_scada",
|
|
||||||
"geo_pumps",
|
|
||||||
"geo_tanks"
|
|
||||||
]);
|
]);
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof scadaDeviceSchema>;
|
||||||
|
|
||||||
|
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
|
export async function fetchScadaDeviceCollection(
|
||||||
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<FeatureCollection<Point, ScadaDevice>> {
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
const projects = projectPageSchema.parse(
|
||||||
|
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
|
||||||
|
);
|
||||||
|
const project = projects.items.find(
|
||||||
|
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
|
||||||
|
);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Feature<Point, ScadaDevice>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
|||||||
junctions: "节点",
|
junctions: "节点",
|
||||||
valves: "阀门",
|
valves: "阀门",
|
||||||
reservoirs: "水库",
|
reservoirs: "水库",
|
||||||
scada: "SCADA"
|
scada: "SCADA",
|
||||||
|
pumps: "水泵",
|
||||||
|
tanks: "水箱"
|
||||||
};
|
};
|
||||||
|
|
||||||
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||||
@@ -63,7 +65,7 @@ const LABEL_PRESETS: Record<
|
|||||||
unit: "m",
|
unit: "m",
|
||||||
options: [
|
options: [
|
||||||
{ value: "elevation", label: "高程 elevation" },
|
{ value: "elevation", label: "高程 elevation" },
|
||||||
{ value: "demand", label: "需水量 demand" }
|
{ value: "base_demand", label: "基础需水量 base_demand" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
valves: {
|
valves: {
|
||||||
@@ -88,6 +90,9 @@ const ERROR_LABELS: Record<string, string> = {
|
|||||||
MAP_NOT_READY: "地图尚未就绪",
|
MAP_NOT_READY: "地图尚未就绪",
|
||||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||||
|
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||||
|
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||||
|
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
|
||||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||||
|
|||||||
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1">{item}</span>
|
<span className="line-clamp-1">{item}</span>
|
||||||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
<StatusBadge tone="info" activity="loading" className="mt-0.5">
|
||||||
|
采集中
|
||||||
|
</StatusBadge>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
|
|||||||
const [statusFilter, setStatusFilter] =
|
const [statusFilter, setStatusFilter] =
|
||||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||||
const mobileSheet = presentation === "mobile-sheet";
|
const mobileSheet = presentation === "mobile-sheet";
|
||||||
|
const desktopDock = presentation === "desktop-dock";
|
||||||
|
const compactPresentation = mobileSheet || desktopDock;
|
||||||
const desktopFloating = presentation === "desktop-floating";
|
const desktopFloating = presentation === "desktop-floating";
|
||||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||||
@@ -298,7 +300,7 @@ export function ScheduledConditionFeed({
|
|||||||
: "max-h-[calc(100dvh-8rem)]"
|
: "max-h-[calc(100dvh-8rem)]"
|
||||||
)
|
)
|
||||||
: "h-full rounded-l-2xl rounded-r-none",
|
: "h-full rounded-l-2xl rounded-r-none",
|
||||||
expanded
|
expanded && !desktopDock
|
||||||
? "scheduled-feed-panel-expanded flex flex-col"
|
? "scheduled-feed-panel-expanded flex flex-col"
|
||||||
: "w-[var(--workbench-condition-width)]"
|
: "w-[var(--workbench-condition-width)]"
|
||||||
)
|
)
|
||||||
@@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||||
mobileSheet
|
compactPresentation
|
||||||
? "flex flex-1"
|
? "flex flex-1"
|
||||||
: expanded
|
: expanded
|
||||||
? "scheduled-feed-layout-expanded flex-1"
|
? "scheduled-feed-layout-expanded flex-1"
|
||||||
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative min-h-0 min-w-0 overflow-hidden",
|
"relative min-h-0 min-w-0 overflow-hidden",
|
||||||
mobileSheet
|
compactPresentation
|
||||||
? expanded
|
? expanded
|
||||||
? "scheduled-feed-detail-enter h-full w-full"
|
? "scheduled-feed-detail-enter h-full w-full"
|
||||||
: "hidden"
|
: "hidden"
|
||||||
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 min-w-0 overflow-hidden",
|
"min-h-0 min-w-0 overflow-hidden",
|
||||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ConditionTimelinePanel
|
<ConditionTimelinePanel
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||||
import { cn } from "@/shared/ui/cn";
|
|
||||||
import { useEffect, type ComponentProps } from "react";
|
import { useEffect, type ComponentProps } from "react";
|
||||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
|
||||||
|
|
||||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||||
|
|
||||||
@@ -10,7 +8,6 @@ type WorkbenchAgentPanelsProps = {
|
|||||||
panelProps: AgentPanelProps;
|
panelProps: AgentPanelProps;
|
||||||
panelOpen: boolean;
|
panelOpen: boolean;
|
||||||
panelCollapsing: boolean;
|
panelCollapsing: boolean;
|
||||||
conditionExpanded: boolean;
|
|
||||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
@@ -23,7 +20,6 @@ export function WorkbenchAgentPanels({
|
|||||||
panelProps,
|
panelProps,
|
||||||
panelOpen,
|
panelOpen,
|
||||||
panelCollapsing,
|
panelCollapsing,
|
||||||
conditionExpanded,
|
|
||||||
personaState,
|
personaState,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
@@ -31,9 +27,7 @@ export function WorkbenchAgentPanels({
|
|||||||
onExpandPanel,
|
onExpandPanel,
|
||||||
onWidthCommit
|
onWidthCommit
|
||||||
}: WorkbenchAgentPanelsProps) {
|
}: WorkbenchAgentPanelsProps) {
|
||||||
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
|
||||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWidthCommit?.(committedWidth);
|
onWidthCommit?.(committedWidth);
|
||||||
@@ -41,43 +35,24 @@ export function WorkbenchAgentPanels({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={resize.panelRef}
|
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
||||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
style={{ width: panelOpen ? committedWidth : 0 }}
|
||||||
style={{ width: resize.width ?? defaultWidth }}
|
|
||||||
>
|
>
|
||||||
{panelOpen ? (
|
{panelOpen ? (
|
||||||
<>
|
|
||||||
<AgentCommandPanel
|
<AgentCommandPanel
|
||||||
{...panelProps}
|
{...panelProps}
|
||||||
presentation="desktop-floating"
|
presentation="desktop-dock"
|
||||||
collapsing={panelCollapsing}
|
collapsing={panelCollapsing}
|
||||||
onCollapse={onCollapsePanel}
|
onCollapse={onCollapsePanel}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
aria-label="调整 Agent 面板宽度"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
|
|
||||||
aria-valuemin={defaultWidth}
|
|
||||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
|
||||||
className={cn(
|
|
||||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
|
||||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
|
||||||
)}
|
|
||||||
role="separator"
|
|
||||||
tabIndex={0}
|
|
||||||
title="拖拽调整 Agent 面板宽度"
|
|
||||||
onKeyDown={resize.handleKeyDown}
|
|
||||||
onPointerDown={resize.handlePointerDown}
|
|
||||||
>
|
|
||||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
|
<div className="absolute left-3 top-5">
|
||||||
<AgentCollapsedRail
|
<AgentCollapsedRail
|
||||||
personaState={personaState}
|
personaState={personaState}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
onExpand={onExpandPanel}
|
onExpand={onExpandPanel}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ export function UserMenu({
|
|||||||
onRefreshTiles,
|
onRefreshTiles,
|
||||||
onShowDataStatus,
|
onShowDataStatus,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onExportConfig
|
onExportConfig,
|
||||||
|
onLogout
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -292,6 +293,7 @@ export function UserMenu({
|
|||||||
onShowDataStatus: () => void;
|
onShowDataStatus: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onExportConfig: () => void;
|
onExportConfig: () => void;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -320,7 +322,15 @@ export function UserMenu({
|
|||||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
<DropdownMenuItem
|
||||||
|
disabled={!onLogout}
|
||||||
|
onSelect={onLogout ? () => void onLogout() : undefined}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2",
|
||||||
|
!onLogout && "text-slate-400",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
<LogOut size={15} aria-hidden="true" />
|
<LogOut size={15} aria-hidden="true" />
|
||||||
退出登录
|
退出登录
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
Sparkles,
|
||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||||
@@ -27,6 +28,7 @@ export type WorkbenchTopBarProps = {
|
|||||||
alerts: WorkbenchAlert[];
|
alerts: WorkbenchAlert[];
|
||||||
user: WorkbenchUser;
|
user: WorkbenchUser;
|
||||||
conditionFeedVisible: boolean;
|
conditionFeedVisible: boolean;
|
||||||
|
timelineVisible: boolean;
|
||||||
taskTickerAvailable: boolean;
|
taskTickerAvailable: boolean;
|
||||||
taskTickerVisible: boolean;
|
taskTickerVisible: boolean;
|
||||||
devPanelEnabled: boolean;
|
devPanelEnabled: boolean;
|
||||||
@@ -34,16 +36,19 @@ export type WorkbenchTopBarProps = {
|
|||||||
onSelectScenario: (scenarioId: string) => void;
|
onSelectScenario: (scenarioId: string) => void;
|
||||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||||
onToggleConditionFeed: () => void;
|
onToggleConditionFeed: () => void;
|
||||||
|
onToggleTimeline: () => void;
|
||||||
onToggleTaskTicker: () => void;
|
onToggleTaskTicker: () => void;
|
||||||
onToggleDevPanel: () => void;
|
onToggleDevPanel: () => void;
|
||||||
onPreviewScenario: () => void;
|
onPreviewScenario: () => void;
|
||||||
onCompareScenario: () => void;
|
onCompareScenario: () => void;
|
||||||
onExportScenarioReport: () => void;
|
onExportScenarioReport: () => void;
|
||||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||||
|
onCreateTestAnalysis?: () => void;
|
||||||
onShowDataStatus: () => void;
|
onShowDataStatus: () => void;
|
||||||
onRefreshTiles: () => void;
|
onRefreshTiles: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onExportConfig: () => void;
|
onExportConfig: () => void;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||||
@@ -56,6 +61,7 @@ export function WorkbenchTopBar({
|
|||||||
alerts,
|
alerts,
|
||||||
user,
|
user,
|
||||||
conditionFeedVisible,
|
conditionFeedVisible,
|
||||||
|
timelineVisible,
|
||||||
taskTickerAvailable,
|
taskTickerAvailable,
|
||||||
taskTickerVisible,
|
taskTickerVisible,
|
||||||
devPanelEnabled,
|
devPanelEnabled,
|
||||||
@@ -63,16 +69,19 @@ export function WorkbenchTopBar({
|
|||||||
onSelectScenario,
|
onSelectScenario,
|
||||||
onSelectAlert,
|
onSelectAlert,
|
||||||
onToggleConditionFeed,
|
onToggleConditionFeed,
|
||||||
|
onToggleTimeline,
|
||||||
onToggleTaskTicker,
|
onToggleTaskTicker,
|
||||||
onToggleDevPanel,
|
onToggleDevPanel,
|
||||||
onPreviewScenario,
|
onPreviewScenario,
|
||||||
onCompareScenario,
|
onCompareScenario,
|
||||||
onExportScenarioReport,
|
onExportScenarioReport,
|
||||||
onAnalyzeAlertsWithAgent,
|
onAnalyzeAlertsWithAgent,
|
||||||
|
onCreateTestAnalysis,
|
||||||
onShowDataStatus,
|
onShowDataStatus,
|
||||||
onRefreshTiles,
|
onRefreshTiles,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onExportConfig
|
onExportConfig,
|
||||||
|
onLogout
|
||||||
}: WorkbenchTopBarProps) {
|
}: WorkbenchTopBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||||
const activeScenario =
|
const activeScenario =
|
||||||
@@ -139,6 +148,29 @@ export function WorkbenchTopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||||
|
</div>
|
||||||
|
{onCreateTestAnalysis ? (
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="生成 Agent 模拟分析成果"
|
||||||
|
title="生成 Agent 模拟分析成果"
|
||||||
|
data-selection-tone="soft"
|
||||||
|
onClick={onCreateTestAnalysis}
|
||||||
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
|
>
|
||||||
|
<span className={headerControlIconClassName}>
|
||||||
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={compactHeaderTextClassName}>测试分析</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{devPanelEnabled ? (
|
{devPanelEnabled ? (
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<button
|
<button
|
||||||
@@ -176,6 +208,7 @@ export function WorkbenchTopBar({
|
|||||||
onShowDataStatus={onShowDataStatus}
|
onShowDataStatus={onShowDataStatus}
|
||||||
onShowShortcuts={onShowShortcuts}
|
onShowShortcuts={onShowShortcuts}
|
||||||
onExportConfig={onExportConfig}
|
onExportConfig={onExportConfig}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -234,6 +267,33 @@ function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TimelineToggle({
|
||||||
|
visible,
|
||||||
|
compact = false,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
||||||
|
aria-pressed={visible}
|
||||||
|
data-selection-tone="soft"
|
||||||
|
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
|
||||||
|
>
|
||||||
|
<span className={headerControlIconClassName}>
|
||||||
|
<Clock3 size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
{!compact ? <span className={compactHeaderTextClassName}>时间轴</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HeaderReadout({
|
function HeaderReadout({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||||
|
|
||||||
describe("map feature interaction state", () => {
|
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", () => {
|
it("maps selected business features to promoted vector feature references", () => {
|
||||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
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() };
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||||
{ selected: true, hovered: false }
|
{ selected: true, hovered: false }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
|
|||||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
clearMapFeatureInteractionState(map, feature, "selected");
|
clearMapFeatureInteractionState(map, feature, "selected");
|
||||||
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||||
"selected"
|
"selected"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
|
|||||||
"junctions-hit",
|
"junctions-hit",
|
||||||
"valves-hit",
|
"valves-hit",
|
||||||
"reservoirs-hit",
|
"reservoirs-hit",
|
||||||
|
"pumps-hit",
|
||||||
|
"tanks-hit",
|
||||||
"scada-hit"
|
"scada-hit"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
|||||||
|
|
||||||
export type MapFeatureInteractionState = {
|
export type MapFeatureInteractionState = {
|
||||||
source: WaterNetworkSourceId;
|
source: WaterNetworkSourceId;
|
||||||
sourceLayer: string;
|
sourceLayer?: string;
|
||||||
id: string;
|
id: string;
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
|
|||||||
feature: Pick<DetailFeature, "id" | "layer"> | null
|
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||||
): FeatureReference | null {
|
): FeatureReference | null {
|
||||||
if (!feature?.id) return 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(
|
export function setMapFeatureInteractionState(
|
||||||
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
|
|||||||
feature: FeatureReference,
|
feature: FeatureReference,
|
||||||
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||||
) {
|
) {
|
||||||
map.setFeatureState(
|
map.setFeatureState(toFeatureStateTarget(feature), state);
|
||||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearMapFeatureInteractionState(
|
export function clearMapFeatureInteractionState(
|
||||||
@@ -44,13 +43,11 @@ export function clearMapFeatureInteractionState(
|
|||||||
feature: FeatureReference,
|
feature: FeatureReference,
|
||||||
key?: "hovered" | "selected"
|
key?: "hovered" | "selected"
|
||||||
) {
|
) {
|
||||||
map.removeFeatureState(
|
map.removeFeatureState(toFeatureStateTarget(feature), key);
|
||||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
|
||||||
key
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseMapInteractionsOptions = {
|
type UseMapInteractionsOptions = {
|
||||||
|
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
@@ -58,6 +55,7 @@ type UseMapInteractionsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useMapInteractions({
|
export function useMapInteractions({
|
||||||
|
availableSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -81,7 +79,9 @@ export function useMapInteractions({
|
|||||||
const id = feature && getFeatureId(feature);
|
const id = feature && getFeatureId(feature);
|
||||||
if (!source || !id) return;
|
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) {
|
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||||
clearHoveredFeature();
|
clearHoveredFeature();
|
||||||
return;
|
return;
|
||||||
@@ -104,7 +104,7 @@ export function useMapInteractions({
|
|||||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitLayerIds = [...INTERACTIVE_HIT_LAYER_IDS];
|
const hitLayerIds = INTERACTIVE_HIT_LAYER_IDS.filter((layerId) => map.getLayer(layerId));
|
||||||
map.on("mousemove", hitLayerIds, handleMouseMove);
|
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.on("click", hitLayerIds, handleClick);
|
map.on("click", hitLayerIds, handleClick);
|
||||||
@@ -115,15 +115,25 @@ export function useMapInteractions({
|
|||||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.off("click", hitLayerIds, handleClick);
|
map.off("click", hitLayerIds, handleClick);
|
||||||
};
|
};
|
||||||
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!mapReady || !map) return;
|
if (!mapReady || !map) return;
|
||||||
const next = toMapFeatureReference(selectedFeature);
|
const next = toMapFeatureReference(selectedFeature);
|
||||||
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
if (next && map.getSource(next.source)) {
|
||||||
|
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
if (next && map.getSource(next.source)) {
|
||||||
|
clearMapFeatureInteractionState(map, next, "selected");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [mapReady, mapRef, selectedFeature]);
|
}, [availableSourceIds, 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import { showMapNotice } from "@/features/map/core";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import {
|
import {
|
||||||
@@ -50,20 +51,25 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
|
|||||||
type UseWorkbenchAgentOptions = {
|
type UseWorkbenchAgentOptions = {
|
||||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
export function useWorkbenchAgent({
|
||||||
|
onUiEnvelope,
|
||||||
|
onFrontendAction,
|
||||||
|
getAccessToken
|
||||||
|
}: UseWorkbenchAgentOptions) {
|
||||||
const collapseTimerRef = useRef<number | null>(null);
|
const collapseTimerRef = useRef<number | null>(null);
|
||||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||||
const clientRef = useRef(createAgentApiClient());
|
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||||
const registryRef = useRef<UIRegistry | null>(null);
|
const registryRef = useRef<UIRegistry | null>(null);
|
||||||
const frontendActionEnabledRef = useRef(false);
|
const frontendActionEnabledRef = useRef(false);
|
||||||
const onFrontendActionRef = useRef(onFrontendAction);
|
const onFrontendActionRef = useRef(onFrontendAction);
|
||||||
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
||||||
const [panelOpen, setPanelOpen] = useState(true);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||||
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
() =>
|
() =>
|
||||||
new DefaultChatTransport<AgentUiMessage>({
|
new DefaultChatTransport<AgentUiMessage>({
|
||||||
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||||
|
headers: async (): Promise<Record<string, string>> => {
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||||
|
},
|
||||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||||
return {
|
return {
|
||||||
body: {
|
body: {
|
||||||
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[]
|
[getAccessToken]
|
||||||
);
|
);
|
||||||
|
|
||||||
const chat = useChat<AgentUiMessage>({
|
const chat = useChat<AgentUiMessage>({
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { FeatureCollection, Point } from "geojson";
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import type { ScadaDevice } from "../api/scada-client";
|
||||||
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
||||||
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||||
|
|
||||||
export function useWorkbenchMapController({
|
export function useWorkbenchMapController({
|
||||||
@@ -9,7 +12,9 @@ export function useWorkbenchMapController({
|
|||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded = false,
|
conditionPanelExpanded = false,
|
||||||
agentPanelWidth
|
agentPanelWidth,
|
||||||
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
}: {
|
}: {
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
@@ -17,6 +22,8 @@ export function useWorkbenchMapController({
|
|||||||
rightPanelOpen: boolean;
|
rightPanelOpen: boolean;
|
||||||
conditionPanelExpanded?: boolean;
|
conditionPanelExpanded?: boolean;
|
||||||
agentPanelWidth?: number;
|
agentPanelWidth?: number;
|
||||||
|
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
availableSourceIds?: readonly WaterNetworkSourceId[];
|
||||||
}) {
|
}) {
|
||||||
const valuesRef = useRef({
|
const valuesRef = useRef({
|
||||||
mapReady,
|
mapReady,
|
||||||
@@ -48,7 +55,8 @@ export function useWorkbenchMapController({
|
|||||||
valuesRef.current.agentPanelWidth
|
valuesRef.current.agentPanelWidth
|
||||||
)
|
)
|
||||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||||
}
|
},
|
||||||
|
getScadaFeatures
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +66,9 @@ export function useWorkbenchMapController({
|
|||||||
controller.getSnapshot,
|
controller.getSnapshot,
|
||||||
controller.getSnapshot
|
controller.getSnapshot
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableSourceIds) controller.syncAvailableSources(availableSourceIds);
|
||||||
|
}, [availableSourceIds, controller]);
|
||||||
useEffect(() => () => controller.destroy(), [controller]);
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
return { controller, state };
|
return { controller, state };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
import type { FeatureCollection, Point } from "geojson";
|
||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type Map as MapLibreMap,
|
||||||
|
type MapSourceDataEvent,
|
||||||
|
type StyleSpecification
|
||||||
|
} 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 { env } from "@/shared/config/env";
|
||||||
|
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
SIMULATION_SOURCE_IDS,
|
SIMULATION_SOURCE_IDS,
|
||||||
@@ -11,20 +19,30 @@ import {
|
|||||||
} from "../map/annotation-layers";
|
} from "../map/annotation-layers";
|
||||||
import { fitNetworkBounds } from "../map/camera";
|
import { fitNetworkBounds } from "../map/camera";
|
||||||
import {
|
import {
|
||||||
|
filterWaterNetworkLayersBySourceIds,
|
||||||
waterNetworkBusinessLayers,
|
waterNetworkBusinessLayers,
|
||||||
waterNetworkHitLayers,
|
waterNetworkHitLayers,
|
||||||
waterNetworkInteractionLayers
|
waterNetworkInteractionLayers
|
||||||
} from "../map/layers";
|
} from "../map/layers";
|
||||||
|
import {
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS,
|
||||||
|
resolveGeoServerLayerAvailability
|
||||||
|
} from "../map/geoserver-layer-availability";
|
||||||
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||||
import { createValueLabelLayers } from "../map/value-label";
|
import { createValueLabelLayers } from "../map/value-label";
|
||||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||||
import {
|
import {
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
createBaseStyle,
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
WATER_NETWORK_GLOBAL_VIEW
|
WATER_NETWORK_GLOBAL_VIEW,
|
||||||
|
type GeoServerWaterNetworkSourceId,
|
||||||
|
type WaterNetworkSourceId
|
||||||
} from "../map/sources";
|
} from "../map/sources";
|
||||||
import {
|
import {
|
||||||
|
SCADA_SOURCE_ID,
|
||||||
registerScadaImages,
|
registerScadaImages,
|
||||||
scadaBusinessLayers,
|
scadaBusinessLayers,
|
||||||
scadaFallbackBusinessLayers,
|
scadaFallbackBusinessLayers,
|
||||||
@@ -44,8 +62,11 @@ declare global {
|
|||||||
type UseWorkbenchMapOptions = {
|
type UseWorkbenchMapOptions = {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
impactVisible: boolean;
|
impactVisible: boolean;
|
||||||
|
layerVisibility: Record<string, boolean>;
|
||||||
|
onClearSelection: () => void;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
selectedFeature: DetailFeature | null;
|
selectedFeature: DetailFeature | null;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseWorkbenchMapResult = {
|
type UseWorkbenchMapResult = {
|
||||||
@@ -53,6 +74,9 @@ type UseWorkbenchMapResult = {
|
|||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
mapError: string | null;
|
mapError: string | null;
|
||||||
sourceStatuses: WorkbenchSourceStatus[];
|
sourceStatuses: WorkbenchSourceStatus[];
|
||||||
|
availableSourceIds: WaterNetworkSourceId[];
|
||||||
|
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
refreshGeoServerLayers: () => boolean;
|
||||||
fitNetworkBounds: () => void;
|
fitNetworkBounds: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +92,8 @@ export type WorkbenchSourceStatus = {
|
|||||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
"mapbox-base": "Mapbox 底图",
|
"mapbox-base": "Mapbox 底图",
|
||||||
"geoserver-mvt": "GeoServer MVT",
|
"geoserver-mvt": "GeoServer MVT",
|
||||||
|
"geoserver-catalog": "GeoServer 图层目录",
|
||||||
|
"scada-api": "SCADA API",
|
||||||
"scada-icons": "SCADA 图标",
|
"scada-icons": "SCADA 图标",
|
||||||
"annotation-source": "业务标注源"
|
"annotation-source": "业务标注源"
|
||||||
};
|
};
|
||||||
@@ -76,34 +102,134 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
|||||||
"mapbox-light": "mapbox-base",
|
"mapbox-light": "mapbox-base",
|
||||||
"mapbox-satellite": "mapbox-base",
|
"mapbox-satellite": "mapbox-base",
|
||||||
...Object.fromEntries(
|
...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.impactArea]: "annotation-source",
|
||||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
export function useWorkbenchMap({
|
export function useWorkbenchMap({
|
||||||
containerRef,
|
containerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
selectedFeature
|
selectedFeature,
|
||||||
|
getAccessToken
|
||||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||||
const mapRef = useRef<MapLibreMap | null>(null);
|
const mapRef = useRef<MapLibreMap | null>(null);
|
||||||
const impactVisibleRef = useRef(impactVisible);
|
const impactVisibleRef = useRef(impactVisible);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [mapError, setMapError] = useState<string | null>(null);
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||||
|
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
|
||||||
|
GeoServerWaterNetworkSourceId[]
|
||||||
|
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
|
||||||
|
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
|
||||||
|
const geoServerLayerProbeInFlightRef = useRef(false);
|
||||||
|
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: []
|
||||||
|
});
|
||||||
|
const scadaLoadPromiseRef = useRef<Promise<FeatureCollection<Point, ScadaDevice>>>(
|
||||||
|
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)))
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshGeoServerLayers = useCallback(() => {
|
||||||
|
if (geoServerLayerProbeInFlightRef.current) return false;
|
||||||
|
geoServerLayerProbeInFlightRef.current = true;
|
||||||
|
setGeoServerLayerRefreshRevision((revision) => revision + 1);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lifecycleAbortController = new AbortController();
|
||||||
|
const requestAbortController = new AbortController();
|
||||||
|
const probeTimeout = window.setTimeout(
|
||||||
|
() => requestAbortController.abort(),
|
||||||
|
GEOSERVER_LAYER_PROBE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
geoServerLayerProbeInFlightRef.current = true;
|
||||||
|
|
||||||
|
void resolveGeoServerLayerAvailability(requestAbortController.signal)
|
||||||
|
.then((availability) => {
|
||||||
|
if (lifecycleAbortController.signal.aborted) return;
|
||||||
|
setAvailableGeoServerSourceIds((current) =>
|
||||||
|
current?.join("|") === availability.availableSourceIds.join("|")
|
||||||
|
? current
|
||||||
|
: availability.availableSourceIds
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availability.failedSourceIds.length > 0) {
|
||||||
|
updateSourceStatus(
|
||||||
|
setSourceStatuses,
|
||||||
|
"geoserver-catalog",
|
||||||
|
"degraded",
|
||||||
|
`无法确认 ${availability.failedSourceIds.join("、")} 图层是否有数据,已隐藏对应入口。`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clearSourceStatus(setSourceStatuses, "geoserver-catalog");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
window.clearTimeout(probeTimeout);
|
||||||
|
if (!lifecycleAbortController.signal.aborted) {
|
||||||
|
geoServerLayerProbeInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
lifecycleAbortController.abort();
|
||||||
|
requestAbortController.abort();
|
||||||
|
geoServerLayerProbeInFlightRef.current = false;
|
||||||
|
window.clearTimeout(probeTimeout);
|
||||||
|
};
|
||||||
|
}, [geoServerLayerRefreshRevision]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
impactVisibleRef.current = impactVisible;
|
impactVisibleRef.current = impactVisible;
|
||||||
}, [impactVisible]);
|
}, [impactVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedFeature &&
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
|
||||||
|
selectedFeature.layer as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
|
||||||
|
) &&
|
||||||
|
!availableGeoServerSourceIds.includes(selectedFeature.layer as GeoServerWaterNetworkSourceId)
|
||||||
|
) {
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
}, [availableGeoServerSourceIds, onClearSelection, selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMapReady(false);
|
||||||
|
setMapError(null);
|
||||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
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({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: createBaseStyle(mapboxToken),
|
style: createBaseStyle(mapboxToken),
|
||||||
@@ -118,10 +244,14 @@ export function useWorkbenchMap({
|
|||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
map.resize();
|
map.resize();
|
||||||
const sources = createWaterNetworkSources();
|
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
scadaCollectionRef.current
|
||||||
map.addSource(sourceId, sources[sourceId])
|
|
||||||
);
|
);
|
||||||
|
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||||
|
const source = sources[sourceId];
|
||||||
|
if (source) map.addSource(sourceId, source);
|
||||||
|
});
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||||
await registerSupplyAssetImages(map);
|
await registerSupplyAssetImages(map);
|
||||||
@@ -133,18 +263,30 @@ export function useWorkbenchMap({
|
|||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkBusinessLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkInteractionLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
createValueLabelLayers(),
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type === "symbol")
|
.filter((layer) => layer.type === "symbol")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkHitLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
@@ -166,11 +308,38 @@ export function useWorkbenchMap({
|
|||||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
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 resizeMap = () => map.resize();
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (resizeFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeFrame = window.requestAnimationFrame(() => {
|
||||||
|
resizeFrame = null;
|
||||||
|
resizeMap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
window.addEventListener("resize", resizeMap);
|
window.addEventListener("resize", resizeMap);
|
||||||
window.setTimeout(resizeMap, 0);
|
|
||||||
window.setTimeout(resizeMap, 300);
|
|
||||||
window.setTimeout(resizeMap, 1000);
|
|
||||||
|
|
||||||
map.on("sourcedata", (event) => {
|
map.on("sourcedata", (event) => {
|
||||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||||
@@ -201,13 +370,25 @@ export function useWorkbenchMap({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", resizeMap);
|
window.removeEventListener("resize", resizeMap);
|
||||||
|
if (resizeFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
scadaAbortController.abort();
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
delete window.__waterNetworkMap;
|
delete window.__waterNetworkMap;
|
||||||
};
|
};
|
||||||
}, [containerRef]);
|
}, [containerRef, getAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) return;
|
||||||
|
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||||
|
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||||
|
|
||||||
useMapInteractions({
|
useMapInteractions({
|
||||||
|
availableSourceIds: availableGeoServerSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -232,10 +413,104 @@ export function useWorkbenchMap({
|
|||||||
mapReady,
|
mapReady,
|
||||||
mapError,
|
mapError,
|
||||||
sourceStatuses: Object.values(sourceStatuses),
|
sourceStatuses: Object.values(sourceStatuses),
|
||||||
|
availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
|
||||||
|
(sourceId) =>
|
||||||
|
sourceId === "scada" ||
|
||||||
|
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
|
||||||
|
),
|
||||||
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers,
|
||||||
fitNetworkBounds: fitToNetworkBounds
|
fitNetworkBounds: fitToNetworkBounds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDataDependentGeoServerLayers(
|
||||||
|
map: MapLibreMap,
|
||||||
|
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||||
|
layerVisibility: Record<string, boolean>
|
||||||
|
) {
|
||||||
|
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
|
||||||
|
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||||
|
if (availableSourceIdSet.has(sourceId)) return;
|
||||||
|
map.getStyle().layers
|
||||||
|
.filter((layer) => "source" in layer && layer.source === sourceId)
|
||||||
|
.reverse()
|
||||||
|
.forEach((layer) => map.removeLayer(layer.id));
|
||||||
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
|
||||||
|
availableSourceIdSet.has(sourceId)
|
||||||
|
);
|
||||||
|
const sources = createWaterNetworkSources(requestedOptionalSourceIds);
|
||||||
|
requestedOptionalSourceIds.forEach((sourceId) => {
|
||||||
|
if (!map.getSource(sourceId) && sources[sourceId]) {
|
||||||
|
map.addSource(sourceId, sources[sourceId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addMissingDataDependentLayers(
|
||||||
|
map,
|
||||||
|
waterNetworkBusinessLayers,
|
||||||
|
[...scadaBusinessLayers, ...scadaFallbackBusinessLayers].map((layer) => layer.id),
|
||||||
|
availableSourceIdSet,
|
||||||
|
layerVisibility
|
||||||
|
);
|
||||||
|
addMissingDataDependentLayers(
|
||||||
|
map,
|
||||||
|
waterNetworkInteractionLayers,
|
||||||
|
scadaInteractionLayers.map((layer) => layer.id),
|
||||||
|
availableSourceIdSet,
|
||||||
|
layerVisibility
|
||||||
|
);
|
||||||
|
addMissingDataDependentLayers(
|
||||||
|
map,
|
||||||
|
createValueLabelLayers(),
|
||||||
|
[
|
||||||
|
...simulationAnnotationLayers.filter((layer) => layer.type === "symbol"),
|
||||||
|
...waterNetworkHitLayers,
|
||||||
|
...scadaHitLayers
|
||||||
|
].map((layer) => layer.id),
|
||||||
|
availableSourceIdSet,
|
||||||
|
layerVisibility
|
||||||
|
);
|
||||||
|
addMissingDataDependentLayers(
|
||||||
|
map,
|
||||||
|
waterNetworkHitLayers,
|
||||||
|
scadaHitLayers.map((layer) => layer.id),
|
||||||
|
availableSourceIdSet,
|
||||||
|
layerVisibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMissingDataDependentLayers(
|
||||||
|
map: MapLibreMap,
|
||||||
|
layers: StyleSpecification["layers"],
|
||||||
|
beforeLayerCandidates: readonly string[],
|
||||||
|
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
|
||||||
|
layerVisibility: Record<string, boolean>
|
||||||
|
) {
|
||||||
|
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
|
||||||
|
layers.forEach((layer) => {
|
||||||
|
if (!("source" in layer) || typeof layer.source !== "string") return;
|
||||||
|
const sourceId = layer.source as GeoServerWaterNetworkSourceId;
|
||||||
|
if (
|
||||||
|
!DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
|
||||||
|
sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
|
||||||
|
) ||
|
||||||
|
!availableSourceIdSet.has(sourceId) ||
|
||||||
|
map.getLayer(layer.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.addLayer(layer, beforeLayerId);
|
||||||
|
if (layerVisibility[sourceId] === false) {
|
||||||
|
map.setLayoutProperty(layer.id, "visibility", "none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateStatusFromSourceEvent(
|
function updateStatusFromSourceEvent(
|
||||||
event: MapSourceDataEvent,
|
event: MapSourceDataEvent,
|
||||||
status: WorkbenchSourceStatusValue,
|
status: WorkbenchSourceStatusValue,
|
||||||
@@ -290,6 +565,22 @@ function updateSourceStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSourceStatus(
|
||||||
|
setSourceStatuses: (
|
||||||
|
updater: (
|
||||||
|
current: Record<string, WorkbenchSourceStatus>
|
||||||
|
) => Record<string, WorkbenchSourceStatus>
|
||||||
|
) => void,
|
||||||
|
sourceGroupId: string
|
||||||
|
) {
|
||||||
|
setSourceStatuses((current) => {
|
||||||
|
if (!current[sourceGroupId]) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[sourceGroupId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
||||||
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
||||||
return SOURCE_GROUP_BY_ID[event.sourceId];
|
return SOURCE_GROUP_BY_ID[event.sourceId];
|
||||||
@@ -326,6 +617,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
|
|||||||
: "业务标注源加载中断,模拟标注可能暂不可见。";
|
: "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceGroupId === "scada-api") {
|
||||||
|
return status === "online"
|
||||||
|
? "SCADA设备已通过API加载。"
|
||||||
|
: "SCADA API请求中断,管网图层仍可继续使用。";
|
||||||
|
}
|
||||||
|
|
||||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,5 +639,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
|
|||||||
return `业务标注源请求失败:${errorMessage}`;
|
return `业务标注源请求失败:${errorMessage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceGroupId === "scada-api") {
|
||||||
|
return `SCADA API请求失败:${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
return errorMessage;
|
return errorMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useWorkbenchResponsiveLayout } from "./use-workbench-responsive-layout";
|
||||||
|
|
||||||
|
function mockViewport(width: number, largeScreen: boolean) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: width
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: largeScreen,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workbench responsive layout", () => {
|
||||||
|
it("coordinates the expanded condition feed with the desktop Agent panel", () => {
|
||||||
|
mockViewport(1366, true);
|
||||||
|
const collapseAgentPanel = vi.fn();
|
||||||
|
const expandAgentPanel = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useWorkbenchResponsiveLayout({
|
||||||
|
activeToolOpen: false,
|
||||||
|
agentPanelOpen: true,
|
||||||
|
devPanelOpen: false,
|
||||||
|
collapseAgentPanel,
|
||||||
|
expandAgentPanel,
|
||||||
|
onClearActiveTool: vi.fn()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isLargeScreen).toBe(true);
|
||||||
|
expect(result.current.shouldShowConditionFeed).toBe(false);
|
||||||
|
|
||||||
|
act(() => result.current.toggleConditionFeedForViewport());
|
||||||
|
expect(result.current.shouldShowConditionFeed).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.handleConditionExpandedChange(true));
|
||||||
|
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||||
|
expect(collapseAgentPanel).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
act(() => result.current.handleConditionExpandedChange(false));
|
||||||
|
expect(result.current.conditionFeedExpanded).toBe(false);
|
||||||
|
expect(expandAgentPanel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the condition sheet without leaking desktop panel state on mobile", () => {
|
||||||
|
mockViewport(390, false);
|
||||||
|
const onClearActiveTool = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useWorkbenchResponsiveLayout({
|
||||||
|
activeToolOpen: true,
|
||||||
|
agentPanelOpen: true,
|
||||||
|
devPanelOpen: false,
|
||||||
|
collapseAgentPanel: vi.fn(),
|
||||||
|
expandAgentPanel: vi.fn(),
|
||||||
|
onClearActiveTool
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.toggleConditionFeedForViewport());
|
||||||
|
expect(result.current.mobileSheet).toBe("condition");
|
||||||
|
expect(result.current.mobileSheetSnap).toBe("half");
|
||||||
|
expect(onClearActiveTool).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
act(() => result.current.toggleConditionFeedForViewport());
|
||||||
|
expect(result.current.mobileSheet).toBeNull();
|
||||||
|
|
||||||
|
act(() => result.current.openAgentPanelForViewport());
|
||||||
|
expect(result.current.mobileSheet).toBe("agent");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
|
||||||
|
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||||
|
|
||||||
|
const CONDITION_FEED_EXIT_MS = 170;
|
||||||
|
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||||
|
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
||||||
|
|
||||||
|
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
|
||||||
|
|
||||||
|
type UseWorkbenchResponsiveLayoutOptions = {
|
||||||
|
activeToolOpen: boolean;
|
||||||
|
agentPanelOpen: boolean;
|
||||||
|
devPanelOpen: boolean;
|
||||||
|
collapseAgentPanel: () => void;
|
||||||
|
expandAgentPanel: () => void;
|
||||||
|
onClearActiveTool: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkbenchResponsiveLayout({
|
||||||
|
activeToolOpen,
|
||||||
|
agentPanelOpen,
|
||||||
|
devPanelOpen,
|
||||||
|
collapseAgentPanel,
|
||||||
|
expandAgentPanel,
|
||||||
|
onClearActiveTool
|
||||||
|
}: UseWorkbenchResponsiveLayoutOptions) {
|
||||||
|
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||||
|
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||||
|
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||||||
|
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||||
|
);
|
||||||
|
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||||
|
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||||
|
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||||
|
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||||
|
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||||
|
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia(LARGE_SCREEN_QUERY);
|
||||||
|
const handleViewportChange = () => {
|
||||||
|
setIsLargeScreen(mediaQuery.matches);
|
||||||
|
setViewportWidth(window.innerWidth);
|
||||||
|
if (mediaQuery.matches) {
|
||||||
|
setMobileSheet(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleViewportChange();
|
||||||
|
setConditionFeedVisible(false);
|
||||||
|
mediaQuery.addEventListener("change", handleViewportChange);
|
||||||
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleViewportChange);
|
||||||
|
window.removeEventListener("resize", handleViewportChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowConditionFeed) {
|
||||||
|
setConditionFeedMounted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setConditionFeedMounted(false);
|
||||||
|
}, CONDITION_FEED_EXIT_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [shouldShowConditionFeed]);
|
||||||
|
|
||||||
|
function closeMobileSheet() {
|
||||||
|
setMobileSheet(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAgentPanelForViewport() {
|
||||||
|
if (isLargeScreen) {
|
||||||
|
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
}
|
||||||
|
expandAgentPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMobileSheet("agent");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||||
|
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||||
|
setAgentCollapsedForCondition(agentPanelOpen);
|
||||||
|
collapseAgentPanel();
|
||||||
|
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
expandAgentPanel();
|
||||||
|
}
|
||||||
|
setConditionFeedExpanded(nextExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
if (agentCollapsedForCondition) {
|
||||||
|
setAgentCollapsedForCondition(false);
|
||||||
|
expandAgentPanel();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agentCollapsedForCondition,
|
||||||
|
conditionFeedExpanded,
|
||||||
|
expandAgentPanel,
|
||||||
|
isLargeScreen,
|
||||||
|
shouldShowConditionFeed
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toggleConditionFeedForViewport() {
|
||||||
|
onClearActiveTool();
|
||||||
|
if (isLargeScreen) {
|
||||||
|
setConditionFeedVisible((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileSheet === "condition") {
|
||||||
|
closeMobileSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConditionFeedExpanded(false);
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
setMobileSheet("condition");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConditionFeedForViewport() {
|
||||||
|
setConditionFeedVisible(true);
|
||||||
|
onClearActiveTool();
|
||||||
|
if (!isLargeScreen) {
|
||||||
|
setMobileSheet("condition");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTimelineForViewport() {
|
||||||
|
if (isLargeScreen) return;
|
||||||
|
setMobileSheet("timeline");
|
||||||
|
setMobileSheetSnap("half");
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
||||||
|
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||||
|
const rightPanelExpanded =
|
||||||
|
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentPanelWidth,
|
||||||
|
closeMobileSheet,
|
||||||
|
conditionFeedExpanded,
|
||||||
|
conditionFeedMounted,
|
||||||
|
handleConditionExpandedChange,
|
||||||
|
isLargeScreen,
|
||||||
|
leftPanelOpen,
|
||||||
|
mobileSheet,
|
||||||
|
mobileSheetSnap,
|
||||||
|
openAgentPanelForViewport,
|
||||||
|
openConditionFeedForViewport,
|
||||||
|
openTimelineForViewport,
|
||||||
|
rightPanelExpanded,
|
||||||
|
rightPanelOpen,
|
||||||
|
setAgentPanelWidth,
|
||||||
|
setMobileSheetSnap,
|
||||||
|
shouldShowConditionFeed,
|
||||||
|
toggleConditionFeedForViewport,
|
||||||
|
viewportWidth
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,3 +17,22 @@ export type {
|
|||||||
LineLayerGroupStylePatch,
|
LineLayerGroupStylePatch,
|
||||||
PointLayerGroupStylePatch
|
PointLayerGroupStylePatch
|
||||||
} from "./map/layer-group-style";
|
} from "./map/layer-group-style";
|
||||||
|
export type {
|
||||||
|
AnalysisArtifact,
|
||||||
|
AnalysisArtifactAction,
|
||||||
|
AnalysisBlock,
|
||||||
|
ArtifactLifecycle,
|
||||||
|
ArtifactMapRelation,
|
||||||
|
ArtifactRequestedView,
|
||||||
|
ArtifactType,
|
||||||
|
ArtifactViewMode,
|
||||||
|
WorkbenchSurfaceMode
|
||||||
|
} from "./workspace/workspace-model";
|
||||||
|
export type {
|
||||||
|
OperationalEvent,
|
||||||
|
OperationalEventImportance,
|
||||||
|
OperationalEventLane,
|
||||||
|
OperationalEventStatus,
|
||||||
|
OperationalTimelineMode
|
||||||
|
} from "./operational-timeline/operational-timeline-model";
|
||||||
|
export type { WorkspaceContext, WorkspaceTimeRange } from "./workspace/workspace-context";
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getAgentPanelMaxWidth,
|
getAgentPanelMaxWidth,
|
||||||
getWorkbenchBasemapTone,
|
getWorkbenchBasemapTone,
|
||||||
getWorkbenchCameraPadding,
|
getWorkbenchCameraPadding,
|
||||||
getWorkbenchViewportLayout
|
getWorkbenchViewportLayout,
|
||||||
|
resolveMobileWorkbenchOverlayLayout
|
||||||
} from "./workbench-layout";
|
} from "./workbench-layout";
|
||||||
|
|
||||||
describe("workbench basemap surface tone", () => {
|
describe("workbench basemap surface tone", () => {
|
||||||
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workbench floating panels", () => {
|
describe("workbench floating panels", () => {
|
||||||
|
it("derives mobile dock and map control offsets from one overlay stack", () => {
|
||||||
|
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
|
||||||
|
timelineBottom: 12,
|
||||||
|
dockBottom: 68,
|
||||||
|
mapControlBottom: 120
|
||||||
|
});
|
||||||
|
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
|
||||||
|
timelineBottom: 12,
|
||||||
|
dockBottom: 12,
|
||||||
|
mapControlBottom: 64
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses bounded desktop and wide panel widths", () => {
|
it("uses bounded desktop and wide panel widths", () => {
|
||||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||||
agentWidth: 500,
|
agentWidth: 500,
|
||||||
@@ -51,6 +65,15 @@ describe("workbench floating panels", () => {
|
|||||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not reserve map width for the collapsed Agent launcher", () => {
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1440, {
|
||||||
|
agentOpen: false,
|
||||||
|
conditionOpen: false
|
||||||
|
}).left
|
||||||
|
).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||||
expect(
|
expect(
|
||||||
getWorkbenchCameraPadding(1920, {
|
getWorkbenchCameraPadding(1920, {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const WORKBENCH_LAYOUT = {
|
|||||||
desktopMinWidth: 1024,
|
desktopMinWidth: 1024,
|
||||||
persistentConditionMinWidth: 1280,
|
persistentConditionMinWidth: 1280,
|
||||||
wideMinWidth: 1536,
|
wideMinWidth: 1536,
|
||||||
collapsedAgentWidth: 72,
|
collapsedAgentWidth: 0,
|
||||||
maxAgentWidth: 720,
|
maxAgentWidth: 720,
|
||||||
agentPanelLeftInset: 12,
|
agentPanelLeftInset: 12,
|
||||||
conditionPanelRightInset: 64,
|
conditionPanelRightInset: 64,
|
||||||
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
|
|||||||
mobileSheet: {
|
mobileSheet: {
|
||||||
halfViewportRatio: 0.52,
|
halfViewportRatio: 0.52,
|
||||||
fullTopGap: 68
|
fullTopGap: 68
|
||||||
|
},
|
||||||
|
mobileOverlay: {
|
||||||
|
edgeInset: 12,
|
||||||
|
gap: 8,
|
||||||
|
dockHeight: 44,
|
||||||
|
timelineSummaryHeight: 48
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
|
||||||
|
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
|
||||||
|
WORKBENCH_LAYOUT.mobileOverlay;
|
||||||
|
const dockBottom = timelineVisible
|
||||||
|
? edgeInset + timelineSummaryHeight + gap
|
||||||
|
: edgeInset;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timelineBottom: edgeInset,
|
||||||
|
dockBottom,
|
||||||
|
mapControlBottom: dockBottom + dockHeight + gap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function clampAgentPanelWidth(
|
export function clampAgentPanelWidth(
|
||||||
width: number,
|
width: number,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
|
|||||||
@@ -2,20 +2,22 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CSSProperties
|
type CSSProperties
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { CalendarClock } from "lucide-react";
|
import { CalendarClock, Clock3 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AgentCommandPanel,
|
AgentCommandPanel,
|
||||||
AgentPersona,
|
AgentPersona,
|
||||||
toTrustedMapAction,
|
toTrustedMapAction,
|
||||||
type FrontendActionRequest,
|
type FrontendActionRequest,
|
||||||
type UIEnvelopePayload,
|
type UIEnvelopePayload,
|
||||||
type AgentUiResult
|
type AgentUiResult,
|
||||||
|
type AgentWorkspaceContextItem
|
||||||
} from "@/features/agent";
|
} from "@/features/agent";
|
||||||
import {
|
import {
|
||||||
MapErrorNotice,
|
MapErrorNotice,
|
||||||
@@ -29,15 +31,22 @@ import {
|
|||||||
type MapSourceStatus
|
type MapSourceStatus
|
||||||
} from "@/features/map/core";
|
} from "@/features/map/core";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||||
import { MapDevPanel } from "./components/map-dev-panel";
|
import { MapDevPanel } from "./components/map-dev-panel";
|
||||||
import {
|
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||||
MobileWorkbenchSheet,
|
|
||||||
type MobileWorkbenchSheetSnap
|
|
||||||
} from "./components/mobile-workbench-sheet";
|
|
||||||
import { FeaturePopover } from "./components/feature-popover";
|
import { FeaturePopover } from "./components/feature-popover";
|
||||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||||
|
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
||||||
|
import {
|
||||||
|
createOperationalEvents,
|
||||||
|
getOperationalEventSummary,
|
||||||
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
|
resolveTimelineScaleLayout,
|
||||||
|
type OperationalEvent,
|
||||||
|
type OperationalTimelineMode
|
||||||
|
} from "./operational-timeline/operational-timeline-model";
|
||||||
import {
|
import {
|
||||||
ToolbarPanel,
|
ToolbarPanel,
|
||||||
type ExportViewPreset,
|
type ExportViewPreset,
|
||||||
@@ -45,11 +54,20 @@ import {
|
|||||||
type ToolbarToolId
|
type ToolbarToolId
|
||||||
} from "./components/toolbar-panel";
|
} from "./components/toolbar-panel";
|
||||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||||
|
import {
|
||||||
|
WorkbenchMainFrame
|
||||||
|
} from "./workspace/workbench-main-frame";
|
||||||
|
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||||
|
import {
|
||||||
|
createInitialWorkspaceContext,
|
||||||
|
reduceWorkspaceContext
|
||||||
|
} from "./workspace/workspace-context";
|
||||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||||
|
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
|
||||||
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
||||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +78,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
WORKBENCH_LAYOUT,
|
WORKBENCH_LAYOUT,
|
||||||
getWorkbenchBasemapTone,
|
getWorkbenchBasemapTone,
|
||||||
getWorkbenchLayoutCssVariables
|
getWorkbenchLayoutCssVariables,
|
||||||
|
resolveMobileWorkbenchOverlayLayout
|
||||||
} from "./layout/workbench-layout";
|
} from "./layout/workbench-layout";
|
||||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||||
import { exportMapViewImage } from "./map/export-view";
|
import { exportMapViewImage } from "./map/export-view";
|
||||||
@@ -69,7 +88,6 @@ import {
|
|||||||
BASE_LAYER_OPTIONS,
|
BASE_LAYER_OPTIONS,
|
||||||
INITIAL_LAYER_VISIBILITY,
|
INITIAL_LAYER_VISIBILITY,
|
||||||
MAP_LEGEND_ITEMS,
|
MAP_LEGEND_ITEMS,
|
||||||
WORKBENCH_LAYER_GROUPS,
|
|
||||||
applyBaseLayerVisibility,
|
applyBaseLayerVisibility,
|
||||||
createLayerControlItems,
|
createLayerControlItems,
|
||||||
getWorkbenchLayerIds
|
getWorkbenchLayerIds
|
||||||
@@ -79,7 +97,8 @@ import type {
|
|||||||
DetailFeature,
|
DetailFeature,
|
||||||
ScheduledConditionItem,
|
ScheduledConditionItem,
|
||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
WorkbenchAlert
|
WorkbenchAlert,
|
||||||
|
WorkbenchUser
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
createScheduledConditionAlerts,
|
createScheduledConditionAlerts,
|
||||||
@@ -89,25 +108,22 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
|||||||
|
|
||||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||||
|
|
||||||
export function MapWorkbenchPage() {
|
export function MapWorkbenchPage({
|
||||||
|
user = WORKBENCH_USER,
|
||||||
|
onLogout,
|
||||||
|
getAccessToken
|
||||||
|
}: {
|
||||||
|
user?: WorkbenchUser;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
|
}) {
|
||||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||||
const [impactVisible, setImpactVisible] = useState(false);
|
const [impactVisible, setImpactVisible] = useState(false);
|
||||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
|
||||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
|
||||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
|
||||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
|
||||||
);
|
|
||||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
|
||||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
|
||||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
|
||||||
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
|
|
||||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
|
||||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
const prefersReducedMotion = useReducedMotion();
|
||||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||||
@@ -140,129 +156,139 @@ export function MapWorkbenchPage() {
|
|||||||
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
||||||
setDetailFeature(feature);
|
setDetailFeature(feature);
|
||||||
}, []);
|
}, []);
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
useEffect(() => {
|
setDetailFeature(null);
|
||||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
|
||||||
const handleChange = () => {
|
|
||||||
setIsLargeScreen(mediaQuery.matches);
|
|
||||||
setViewportWidth(window.innerWidth);
|
|
||||||
if (mediaQuery.matches) {
|
|
||||||
setMobileSheet(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange();
|
|
||||||
setConditionFeedVisible(window.innerWidth >= 1280);
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
|
||||||
window.addEventListener("resize", handleChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
|
||||||
window.removeEventListener("resize", handleChange);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldShowConditionFeed) {
|
|
||||||
setConditionFeedMounted(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setConditionFeedMounted(false);
|
|
||||||
}, 170);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, [shouldShowConditionFeed]);
|
|
||||||
|
|
||||||
const agent = useWorkbenchAgent({
|
const agent = useWorkbenchAgent({
|
||||||
onUiEnvelope: handleAgentUiEnvelope,
|
onUiEnvelope: handleAgentUiEnvelope,
|
||||||
onFrontendAction: handleFrontendAction
|
onFrontendAction: handleFrontendAction,
|
||||||
|
getAccessToken
|
||||||
});
|
});
|
||||||
function openAgentPanelForViewport() {
|
const collapseAgentPanel = agent.collapsePanel;
|
||||||
if (isLargeScreen) {
|
const expandAgentPanel = agent.expandPanel;
|
||||||
if (conditionFeedExpanded && viewportWidth < 1440) {
|
const clearActiveTool = useCallback(() => {
|
||||||
setConditionFeedExpanded(false);
|
setActiveToolId(null);
|
||||||
setAgentCollapsedForCondition(false);
|
|
||||||
}
|
|
||||||
agent.expandPanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMobileSheet("agent");
|
|
||||||
setMobileSheetSnap("half");
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMobileSheet = useCallback(() => {
|
|
||||||
setMobileSheet(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
const {
|
||||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
agentPanelWidth,
|
||||||
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
closeMobileSheet,
|
||||||
setAgentCollapsedForCondition(agent.panelOpen);
|
conditionFeedExpanded,
|
||||||
agent.collapsePanel();
|
conditionFeedMounted,
|
||||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
handleConditionExpandedChange,
|
||||||
setAgentCollapsedForCondition(false);
|
isLargeScreen,
|
||||||
agent.expandPanel();
|
leftPanelOpen,
|
||||||
}
|
mobileSheet,
|
||||||
setConditionFeedExpanded(nextExpanded);
|
mobileSheetSnap,
|
||||||
}
|
openAgentPanelForViewport,
|
||||||
|
openConditionFeedForViewport,
|
||||||
|
openTimelineForViewport,
|
||||||
|
rightPanelExpanded,
|
||||||
|
rightPanelOpen,
|
||||||
|
setAgentPanelWidth,
|
||||||
|
setMobileSheetSnap,
|
||||||
|
shouldShowConditionFeed,
|
||||||
|
toggleConditionFeedForViewport,
|
||||||
|
viewportWidth
|
||||||
|
} = useWorkbenchResponsiveLayout({
|
||||||
|
activeToolOpen: activeToolId !== null,
|
||||||
|
agentPanelOpen: agent.panelOpen,
|
||||||
|
devPanelOpen,
|
||||||
|
collapseAgentPanel,
|
||||||
|
expandAgentPanel,
|
||||||
|
onClearActiveTool: clearActiveTool
|
||||||
|
});
|
||||||
|
const currentAgentWidth = leftPanelOpen
|
||||||
|
? agentPanelWidth
|
||||||
|
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||||
|
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
|
||||||
|
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
|
||||||
|
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||||
|
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
|
||||||
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||||
|
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
|
||||||
|
reduceWorkspaceContext,
|
||||||
|
undefined,
|
||||||
|
() => createInitialWorkspaceContext()
|
||||||
|
);
|
||||||
|
const operationalEvents = useMemo(
|
||||||
|
() => createOperationalEvents(scheduledConditions),
|
||||||
|
[scheduledConditions]
|
||||||
|
);
|
||||||
|
const operationalSummary = useMemo(
|
||||||
|
() => getOperationalEventSummary(operationalEvents),
|
||||||
|
[operationalEvents]
|
||||||
|
);
|
||||||
|
const timelineScaleLayout = resolveTimelineScaleLayout(
|
||||||
|
businessWorkspaceWidth,
|
||||||
|
timelineMode,
|
||||||
|
timelineVisible
|
||||||
|
);
|
||||||
|
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
|
||||||
|
const timelineBottom = isLargeScreen
|
||||||
|
? timelineScaleLayout.timelineBottom
|
||||||
|
: mobileOverlayLayout.timelineBottom;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
setTimelineMode((current) => {
|
||||||
return;
|
if (artifactWorkspace.surfaceMode === "focus") return "summary";
|
||||||
|
return current === "summary" ? "compact" : current;
|
||||||
|
});
|
||||||
|
}, [artifactWorkspace.surfaceMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatchWorkspaceContext({
|
||||||
|
type: "sync-artifact",
|
||||||
|
artifact: artifactWorkspace.activeArtifact
|
||||||
|
});
|
||||||
|
}, [artifactWorkspace.activeArtifact]);
|
||||||
|
|
||||||
|
function openAgentWorkspaceForViewport() {
|
||||||
|
openAgentPanelForViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
setConditionFeedExpanded(false);
|
function openConditionWorkspaceForViewport() {
|
||||||
if (agentCollapsedForCondition) {
|
openConditionFeedForViewport();
|
||||||
setAgentCollapsedForCondition(false);
|
|
||||||
agent.expandPanel();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
agent,
|
|
||||||
agentCollapsedForCondition,
|
|
||||||
conditionFeedExpanded,
|
|
||||||
isLargeScreen,
|
|
||||||
shouldShowConditionFeed
|
|
||||||
]);
|
|
||||||
|
|
||||||
function toggleConditionFeedForViewport() {
|
|
||||||
setActiveToolId(null);
|
|
||||||
if (isLargeScreen) {
|
|
||||||
setConditionFeedVisible((current) => !current);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mobileSheet === "condition") {
|
function toggleConditionWorkspaceForViewport() {
|
||||||
closeMobileSheet();
|
toggleConditionFeedForViewport();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setConditionFeedExpanded(false);
|
function toggleTimelineVisibility() {
|
||||||
setMobileSheetSnap("half");
|
setTimelineVisible((current) => {
|
||||||
setMobileSheet("condition");
|
if (current && mobileSheet === "timeline") closeMobileSheet();
|
||||||
|
return !current;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
const {
|
||||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
mapRef,
|
||||||
const rightPanelExpanded =
|
mapReady,
|
||||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
mapError,
|
||||||
|
sourceStatuses,
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
availableSourceIds,
|
||||||
|
fitNetworkBounds,
|
||||||
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers
|
||||||
|
} = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection: handleClearSelection,
|
||||||
onSelectFeature: handleSelectFeature,
|
onSelectFeature: handleSelectFeature,
|
||||||
selectedFeature: detailFeature
|
selectedFeature: detailFeature,
|
||||||
|
getAccessToken
|
||||||
});
|
});
|
||||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
leftPanelOpen,
|
leftPanelOpen: false,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth,
|
||||||
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeAgentUiResults = useMemo(
|
const activeAgentUiResults = useMemo(
|
||||||
@@ -298,7 +324,11 @@ export function MapWorkbenchPage() {
|
|||||||
});
|
});
|
||||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||||
const shouldShowTaskTicker =
|
const shouldShowTaskTicker =
|
||||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
!artifactActive
|
||||||
|
&& mobileSheet === null
|
||||||
|
&& !conditionFeedExpanded
|
||||||
|
&& taskTickerVisible
|
||||||
|
&& taskTickerAvailable;
|
||||||
const taskTickerEnterState = prefersReducedMotion
|
const taskTickerEnterState = prefersReducedMotion
|
||||||
? {
|
? {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -351,6 +381,54 @@ export function MapWorkbenchPage() {
|
|||||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
|
||||||
|
dispatchWorkspaceContext({
|
||||||
|
type: "select-operational-event",
|
||||||
|
eventId: event.id,
|
||||||
|
cursorTime: event.cursorTime
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapTarget = event.mapTargets[0];
|
||||||
|
if (mapTarget) {
|
||||||
|
dispatchWorkspaceContext({
|
||||||
|
type: "select-spatial-feature",
|
||||||
|
featureId: mapTarget.featureId,
|
||||||
|
sourceId: mapTarget.sourceId
|
||||||
|
});
|
||||||
|
void mapController.locateAndHighlight(mapTarget).then(() => {
|
||||||
|
if (mapController.getSnapshot().errorCode) {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "warning",
|
||||||
|
title: "已选择运行事件",
|
||||||
|
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.conditionId) {
|
||||||
|
setSelectedConditionId(event.conditionId);
|
||||||
|
setConditionFocusRequest((current) => ({
|
||||||
|
conditionId: event.conditionId!,
|
||||||
|
requestId: (current?.requestId ?? 0) + 1
|
||||||
|
}));
|
||||||
|
openConditionFeedForViewport();
|
||||||
|
handleConditionExpandedChange(true);
|
||||||
|
}
|
||||||
|
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
|
||||||
|
|
||||||
|
const handleClearOperationalSelection = useCallback(() => {
|
||||||
|
dispatchWorkspaceContext({ type: "clear-operational-event" });
|
||||||
|
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||||
|
mapController.clearHighlight();
|
||||||
|
}, [mapController]);
|
||||||
|
|
||||||
|
const handleReturnOperationalNow = useCallback(() => {
|
||||||
|
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
|
||||||
|
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||||
|
mapController.clearHighlight();
|
||||||
|
}, [mapController]);
|
||||||
|
|
||||||
const toolbarItems = useMemo<MapToolbarItem[]>(
|
const toolbarItems = useMemo<MapToolbarItem[]>(
|
||||||
() =>
|
() =>
|
||||||
waterNetworkToolbarItems.map((item) => ({
|
waterNetworkToolbarItems.map((item) => ({
|
||||||
@@ -363,8 +441,8 @@ export function MapWorkbenchPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const layerControlItems = useMemo(
|
const layerControlItems = useMemo(
|
||||||
() => createLayerControlItems(layerVisibility),
|
() => createLayerControlItems(layerVisibility, availableSourceIds),
|
||||||
[layerVisibility]
|
[availableSourceIds, layerVisibility]
|
||||||
);
|
);
|
||||||
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -379,9 +457,7 @@ export function MapWorkbenchPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +507,15 @@ export function MapWorkbenchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!refreshGeoServerLayers()) {
|
||||||
|
showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
map.triggerRepaint();
|
map.triggerRepaint();
|
||||||
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
|
showMapNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: "已开始重新检查业务图层,并请求地图重新渲染。"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowDataStatus() {
|
function handleShowDataStatus() {
|
||||||
@@ -476,11 +559,10 @@ export function MapWorkbenchPage() {
|
|||||||
setImpactVisible(true);
|
setImpactVisible(true);
|
||||||
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
||||||
|
|
||||||
if (mapRef.current && mapReady) {
|
const map = mapRef.current;
|
||||||
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
|
if (map && mapReady) {
|
||||||
if (mapRef.current?.getLayer(layerId)) {
|
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
|
||||||
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
|
map.setLayoutProperty(layerId, "visibility", "visible");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +604,7 @@ export function MapWorkbenchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openAgentPanelForViewport();
|
openAgentWorkspaceForViewport();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.submitPrompt(
|
await agent.submitPrompt(
|
||||||
@@ -559,12 +641,7 @@ export function MapWorkbenchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConditionFeedVisible(true);
|
openConditionWorkspaceForViewport();
|
||||||
setActiveToolId(null);
|
|
||||||
if (!isLargeScreen) {
|
|
||||||
setMobileSheet("condition");
|
|
||||||
setMobileSheetSnap("half");
|
|
||||||
}
|
|
||||||
setConditionFocusRequest((current) => ({
|
setConditionFocusRequest((current) => ({
|
||||||
conditionId,
|
conditionId,
|
||||||
requestId: (current?.requestId ?? 0) + 1
|
requestId: (current?.requestId ?? 0) + 1
|
||||||
@@ -686,7 +763,7 @@ export function MapWorkbenchPage() {
|
|||||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||||
padding: getResponsiveWorkbenchPadding(
|
padding: getResponsiveWorkbenchPadding(
|
||||||
map,
|
map,
|
||||||
leftPanelOpen,
|
false,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
rightPanelExpanded,
|
rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth
|
||||||
@@ -804,6 +881,35 @@ export function MapWorkbenchPage() {
|
|||||||
onShowShortcuts: handleShowShortcuts,
|
onShowShortcuts: handleShowShortcuts,
|
||||||
onExportConfig: handleExportConfig
|
onExportConfig: handleExportConfig
|
||||||
};
|
};
|
||||||
|
const selectedOperationalEvent = operationalEvents.find(
|
||||||
|
(event) => event.id === workspaceContext.operational.selectedEventId
|
||||||
|
) ?? null;
|
||||||
|
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
|
||||||
|
const items: AgentWorkspaceContextItem[] = [{
|
||||||
|
id: "operational-time",
|
||||||
|
label: "运行时点",
|
||||||
|
value: formatContextClock(workspaceContext.operational.cursorTime),
|
||||||
|
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (workspaceContext.spatial.featureId) {
|
||||||
|
items.push({
|
||||||
|
id: "spatial-feature",
|
||||||
|
label: "地图要素",
|
||||||
|
value: workspaceContext.spatial.featureId,
|
||||||
|
tone: "info"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (artifactWorkspace.activeArtifact) {
|
||||||
|
items.push({
|
||||||
|
id: "analytical-artifact",
|
||||||
|
label: "分析成果",
|
||||||
|
value: artifactWorkspace.activeArtifact.title,
|
||||||
|
tone: "info"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
|
||||||
const agentPanelProps: Omit<
|
const agentPanelProps: Omit<
|
||||||
ComponentProps<typeof AgentCommandPanel>,
|
ComponentProps<typeof AgentCommandPanel>,
|
||||||
"collapsing" | "onCollapse"
|
"collapsing" | "onCollapse"
|
||||||
@@ -822,6 +928,7 @@ export function MapWorkbenchPage() {
|
|||||||
selectedModel: agent.selectedModel,
|
selectedModel: agent.selectedModel,
|
||||||
approvalMode: agent.approvalMode,
|
approvalMode: agent.approvalMode,
|
||||||
uiResults: activeAgentUiResults,
|
uiResults: activeAgentUiResults,
|
||||||
|
workspaceContext: agentWorkspaceContext,
|
||||||
onRefreshHistory: agent.refreshSessionHistory,
|
onRefreshHistory: agent.refreshSessionHistory,
|
||||||
onStartNewSession: handleStartNewAgentSession,
|
onStartNewSession: handleStartNewAgentSession,
|
||||||
onLoadHistorySession: handleLoadAgentHistorySession,
|
onLoadHistorySession: handleLoadAgentHistorySession,
|
||||||
@@ -843,51 +950,135 @@ export function MapWorkbenchPage() {
|
|||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||||
"--workbench-agent-current-width": `${
|
"--workbench-agent-current-width": `${currentAgentWidth}px`,
|
||||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
"--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
|
||||||
}px`
|
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
ref={mapContainerRef}
|
|
||||||
className="map-grid"
|
|
||||||
style={{ position: "absolute", inset: 0 }}
|
|
||||||
aria-label="供水管网地图"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WorkbenchTopBar
|
<WorkbenchTopBar
|
||||||
dataTime={headerDataTime}
|
dataTime={headerDataTime}
|
||||||
modelName="AquaDispatch v1.0"
|
modelName="AquaDispatch v1.0"
|
||||||
scenarios={WORKBENCH_SCENARIOS}
|
scenarios={WORKBENCH_SCENARIOS}
|
||||||
activeScenarioId={activeScenarioId}
|
activeScenarioId={activeScenarioId}
|
||||||
alerts={workbenchAlerts}
|
alerts={workbenchAlerts}
|
||||||
user={WORKBENCH_USER}
|
user={user}
|
||||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||||
|
timelineVisible={timelineVisible}
|
||||||
taskTickerAvailable={taskTickerAvailable}
|
taskTickerAvailable={taskTickerAvailable}
|
||||||
taskTickerVisible={taskTickerVisible}
|
taskTickerVisible={taskTickerVisible}
|
||||||
devPanelEnabled={devPanelEnabled}
|
devPanelEnabled={devPanelEnabled}
|
||||||
devPanelOpen={devPanelOpen}
|
devPanelOpen={devPanelOpen}
|
||||||
onSelectScenario={handleSelectScenario}
|
onSelectScenario={handleSelectScenario}
|
||||||
onSelectAlert={handleSelectAlert}
|
onSelectAlert={handleSelectAlert}
|
||||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||||
|
onToggleTimeline={toggleTimelineVisibility}
|
||||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||||
onPreviewScenario={handlePreviewScenario}
|
onPreviewScenario={handlePreviewScenario}
|
||||||
onCompareScenario={handleCompareScenario}
|
onCompareScenario={handleCompareScenario}
|
||||||
onExportScenarioReport={handleExportScenarioReport}
|
onExportScenarioReport={handleExportScenarioReport}
|
||||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||||
|
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
|
||||||
onShowDataStatus={handleShowDataStatus}
|
onShowDataStatus={handleShowDataStatus}
|
||||||
onRefreshTiles={handleRefreshTiles}
|
onRefreshTiles={handleRefreshTiles}
|
||||||
onShowShortcuts={handleShowShortcuts}
|
onShowShortcuts={handleShowShortcuts}
|
||||||
onExportConfig={handleExportConfig}
|
onExportConfig={handleExportConfig}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkbenchMainFrame
|
||||||
|
activeArtifact={artifactWorkspace.activeArtifact}
|
||||||
|
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||||
|
surfaceMode={artifactWorkspace.surfaceMode}
|
||||||
|
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
|
||||||
|
onOpenPendingArtifact={artifactWorkspace.openPreview}
|
||||||
|
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
|
||||||
|
onSetArtifactView={artifactWorkspace.setView}
|
||||||
|
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||||
|
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
||||||
|
timelineMode={timelineMode}
|
||||||
|
timelineBottom={timelineBottom}
|
||||||
|
timelineContent={(
|
||||||
|
timelineVisible ? <>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<OperationalTimeline
|
||||||
|
date={workspaceContext.operational.date}
|
||||||
|
cursorTime={workspaceContext.operational.cursorTime}
|
||||||
|
events={operationalEvents}
|
||||||
|
mode={timelineMode}
|
||||||
|
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||||
|
onModeChange={setTimelineMode}
|
||||||
|
onSelectEvent={handleSelectOperationalEvent}
|
||||||
|
onClearSelection={handleClearOperationalSelection}
|
||||||
|
onReturnNow={handleReturnOperationalNow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="移动端运行时间轴摘要"
|
||||||
|
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="打开完整运行时间轴"
|
||||||
|
onClick={openTimelineForViewport}
|
||||||
|
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
|
||||||
|
>
|
||||||
|
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
|
<span className="shrink-0 font-semibold">运行时间轴</span>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
|
||||||
|
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||||
|
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
||||||
|
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
|
||||||
|
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
|
||||||
|
查看全天
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</> : null
|
||||||
|
)}
|
||||||
|
mapContent={(
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="map-grid absolute inset-0"
|
||||||
|
style={{ position: "absolute", inset: 0 }}
|
||||||
|
aria-label="供水管网地图"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
mapOverlay={(
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
|
||||||
|
<MapToolbar items={toolbarItems} />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
||||||
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
|
||||||
|
style={{
|
||||||
|
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
|
||||||
|
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
|
||||||
|
} as CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="flex items-end gap-2 lg:pr-2">
|
||||||
|
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||||
|
</div>
|
||||||
|
<MapScaleLine
|
||||||
|
className="hidden lg:inline-flex"
|
||||||
|
mapRef={mapRef}
|
||||||
|
mapReady={mapReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkbenchAgentPanels
|
<WorkbenchAgentPanels
|
||||||
panelProps={agentPanelProps}
|
panelProps={agentPanelProps}
|
||||||
panelOpen={agent.panelOpen}
|
panelOpen={agent.panelOpen}
|
||||||
panelCollapsing={agent.panelCollapsing}
|
panelCollapsing={agent.panelCollapsing}
|
||||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
|
||||||
personaState={agent.personaState}
|
personaState={agent.personaState}
|
||||||
statusLabel={agent.statusLabel}
|
statusLabel={agent.statusLabel}
|
||||||
viewportWidth={viewportWidth}
|
viewportWidth={viewportWidth}
|
||||||
@@ -896,14 +1087,8 @@ export function MapWorkbenchPage() {
|
|||||||
onWidthCommit={setAgentPanelWidth}
|
onWidthCommit={setAgentPanelWidth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!devPanelOpen ? (
|
|
||||||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
|
||||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{conditionFeedMounted && !devPanelOpen ? (
|
{conditionFeedMounted && !devPanelOpen ? (
|
||||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||||
<ScheduledConditionFeed
|
<ScheduledConditionFeed
|
||||||
presentation="desktop-floating"
|
presentation="desktop-floating"
|
||||||
conditions={scheduledConditions}
|
conditions={scheduledConditions}
|
||||||
@@ -915,19 +1100,13 @@ export function MapWorkbenchPage() {
|
|||||||
onExpandedChange={handleConditionExpandedChange}
|
onExpandedChange={handleConditionExpandedChange}
|
||||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||||
onSelectedConditionChange={setSelectedConditionId}
|
onSelectedConditionChange={setSelectedConditionId}
|
||||||
onExpandAgent={openAgentPanelForViewport}
|
onExpandAgent={openAgentWorkspaceForViewport}
|
||||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||||
onSubmitPrompt={agent.submitPrompt}
|
onSubmitPrompt={agent.submitPrompt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!devPanelOpen ? (
|
|
||||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
|
||||||
<ToolbarPanel {...toolbarPanelProps} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{devPanelEnabled && devPanelOpen ? (
|
{devPanelEnabled && devPanelOpen ? (
|
||||||
<MapDevPanel
|
<MapDevPanel
|
||||||
commands={mapController}
|
commands={mapController}
|
||||||
@@ -937,8 +1116,11 @@ export function MapWorkbenchPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mobileSheet === null ? (
|
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
<div
|
||||||
|
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
|
||||||
|
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
|
||||||
|
>
|
||||||
<ToolbarPanel
|
<ToolbarPanel
|
||||||
{...toolbarPanelProps}
|
{...toolbarPanelProps}
|
||||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||||
@@ -946,18 +1128,11 @@ export function MapWorkbenchPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
|
||||||
<div className="flex items-end gap-2 pr-2">
|
|
||||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
|
||||||
</div>
|
|
||||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{shouldShowTaskTicker ? (
|
{shouldShowTaskTicker ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="agent-task-ticker-shell"
|
key="agent-task-ticker-shell"
|
||||||
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||||
style={{ transformOrigin: "top center" }}
|
style={{ transformOrigin: "top center" }}
|
||||||
initial={
|
initial={
|
||||||
prefersReducedMotion
|
prefersReducedMotion
|
||||||
@@ -976,7 +1151,10 @@ export function MapWorkbenchPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
<div
|
||||||
|
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
|
||||||
|
style={{ bottom: mobileOverlayLayout.dockBottom }}
|
||||||
|
>
|
||||||
{mobileSheet === null ? (
|
{mobileSheet === null ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -988,11 +1166,13 @@ export function MapWorkbenchPage() {
|
|||||||
>
|
>
|
||||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||||
</button>
|
</button>
|
||||||
|
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<MapToolbar
|
<MapToolbar
|
||||||
items={toolbarItems}
|
items={toolbarItems}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className="h-11 justify-center"
|
className="h-11 justify-center"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="打开工况任务"
|
aria-label="打开工况任务"
|
||||||
@@ -1008,7 +1188,13 @@ export function MapWorkbenchPage() {
|
|||||||
|
|
||||||
{mobileSheet ? (
|
{mobileSheet ? (
|
||||||
<MobileWorkbenchSheet
|
<MobileWorkbenchSheet
|
||||||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
label={
|
||||||
|
mobileSheet === "agent"
|
||||||
|
? "Agent 工作台抽屉"
|
||||||
|
: mobileSheet === "timeline"
|
||||||
|
? "运行时间轴抽屉"
|
||||||
|
: "工况任务抽屉"
|
||||||
|
}
|
||||||
snap={mobileSheetSnap}
|
snap={mobileSheetSnap}
|
||||||
onSnapChange={setMobileSheetSnap}
|
onSnapChange={setMobileSheetSnap}
|
||||||
onClose={closeMobileSheet}
|
onClose={closeMobileSheet}
|
||||||
@@ -1019,6 +1205,19 @@ export function MapWorkbenchPage() {
|
|||||||
presentation="mobile-sheet"
|
presentation="mobile-sheet"
|
||||||
onCollapse={closeMobileSheet}
|
onCollapse={closeMobileSheet}
|
||||||
/>
|
/>
|
||||||
|
) : mobileSheet === "timeline" ? (
|
||||||
|
<OperationalTimeline
|
||||||
|
mobile
|
||||||
|
date={workspaceContext.operational.date}
|
||||||
|
cursorTime={workspaceContext.operational.cursorTime}
|
||||||
|
events={operationalEvents}
|
||||||
|
mode="expanded"
|
||||||
|
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||||
|
onModeChange={closeMobileSheet}
|
||||||
|
onSelectEvent={handleSelectOperationalEvent}
|
||||||
|
onClearSelection={handleClearOperationalSelection}
|
||||||
|
onReturnNow={handleReturnOperationalNow}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScheduledConditionFeed
|
<ScheduledConditionFeed
|
||||||
presentation="mobile-sheet"
|
presentation="mobile-sheet"
|
||||||
@@ -1091,3 +1290,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
|||||||
|
|
||||||
return "当前分辨率";
|
return "当前分辨率";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatContextClock(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
|
|||||||
{
|
{
|
||||||
agentOpen: false,
|
agentOpen: false,
|
||||||
conditionOpen: false,
|
conditionOpen: false,
|
||||||
expected: { top: 72, right: 72, bottom: 32, left: 96 }
|
expected: { top: 72, right: 72, bottom: 32, left: 24 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
agentOpen: true,
|
agentOpen: true,
|
||||||
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
|
|||||||
{
|
{
|
||||||
agentOpen: false,
|
agentOpen: false,
|
||||||
conditionOpen: true,
|
conditionOpen: true,
|
||||||
expected: { top: 72, right: 504, bottom: 32, left: 96 }
|
expected: { top: 72, right: 504, bottom: 32, left: 24 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
agentOpen: true,
|
agentOpen: true,
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
|
|||||||
});
|
});
|
||||||
if (selectedFeature) {
|
if (selectedFeature) {
|
||||||
exportMap.setFeatureState(
|
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 }
|
{ selected: true, hovered: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import type { MapGeoJSONFeature } from "maplibre-gl";
|
import type { MapGeoJSONFeature } from "maplibre-gl";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import { formatValue } from "../utils/format-value";
|
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) {
|
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);
|
return raw === undefined || raw === null ? "" : String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
|
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) {
|
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
|
||||||
return sourceId;
|
return sourceId;
|
||||||
}
|
}
|
||||||
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
|
|||||||
if (sourceId === "valves") {
|
if (sourceId === "valves") {
|
||||||
return {
|
return {
|
||||||
title: `阀门 ${id || "未命名"}`,
|
title: `阀门 ${id || "未命名"}`,
|
||||||
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
|
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceId === "reservoirs") {
|
if (sourceId === "reservoirs") {
|
||||||
return {
|
return {
|
||||||
title: `水库 ${id || "未命名"}`,
|
title: `水库 ${id || "未命名"}`,
|
||||||
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
|
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceId === "scada") {
|
if (sourceId === "scada") {
|
||||||
|
const associatedElementId = properties.node_id ?? properties.link_id;
|
||||||
return {
|
return {
|
||||||
title: `SCADA ${id || "未命名"}`,
|
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 {
|
return {
|
||||||
title: `节点 ${id || "未命名"}`,
|
title: `节点 ${id || "未命名"}`,
|
||||||
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m`
|
subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ describe("flow overlay", () => {
|
|||||||
|
|
||||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat",
|
"source-layer": "pipes",
|
||||||
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
||||||
});
|
});
|
||||||
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat",
|
"source-layer": "pipes",
|
||||||
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
||||||
});
|
});
|
||||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||||
@@ -56,7 +56,7 @@ describe("flow overlay", () => {
|
|||||||
|
|
||||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
sourceLayer: "geo_pipes_mat",
|
sourceLayer: "pipes",
|
||||||
id: "P-1"
|
id: "P-1"
|
||||||
}, {
|
}, {
|
||||||
flowDirection: -1
|
flowDirection: -1
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createGeoServerLayerHitsUrl,
|
||||||
|
fetchGeoServerLayerFeatureCount,
|
||||||
|
resolveGeoServerLayerAvailability
|
||||||
|
} from "./geoserver-layer-availability";
|
||||||
|
|
||||||
|
describe("GeoServer data-dependent layer availability", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a WFS hits request for the canonical workspace layer", () => {
|
||||||
|
const url = createGeoServerLayerHitsUrl("pumps");
|
||||||
|
|
||||||
|
expect(url.pathname).toBe("/geoserver/tjwater_next/ows");
|
||||||
|
expect(url.searchParams.get("service")).toBe("WFS");
|
||||||
|
expect(url.searchParams.get("typeNames")).toBe("tjwater_next:pumps");
|
||||||
|
expect(url.searchParams.get("resultType")).toBe("hits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads numberMatched without downloading layer features", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
'<wfs:FeatureCollection numberMatched="12" numberReturned="0" />',
|
||||||
|
{ status: 200 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a published WFS layer with an unknown count as available", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
'<wfs:FeatureCollection numberMatched="unknown" numberReturned="0" />',
|
||||||
|
{ status: 200 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only optional layers that contain features", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="3" />'))
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
|
||||||
|
availableSourceIds: ["pipes", "junctions", "pumps"],
|
||||||
|
emptySourceIds: ["valves", "reservoirs", "tanks"],
|
||||||
|
failedSourceIds: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unpublished GeoServer feature type as empty", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
"<ows:ExceptionText>Feature type tjwater_next:pumps unknown</ows:ExceptionText>",
|
||||||
|
{ status: 400 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits invalid optional layers without blocking required layers", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
|
||||||
|
.mockResolvedValueOnce(new Response("<ServiceException />"))
|
||||||
|
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
|
||||||
|
.mockResolvedValueOnce(new Response("<ServiceException />"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
|
||||||
|
availableSourceIds: ["pipes", "junctions"],
|
||||||
|
emptySourceIds: [],
|
||||||
|
failedSourceIds: ["valves", "reservoirs", "pumps", "tanks"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
|
import {
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
SOURCE_LAYERS,
|
||||||
|
SUPPLY_LAYER_CATALOG,
|
||||||
|
type GeoServerWaterNetworkSourceId
|
||||||
|
} from "./sources";
|
||||||
|
|
||||||
|
type GeoServerSupplyLayerCatalogItem = Extract<
|
||||||
|
(typeof SUPPLY_LAYER_CATALOG)[number],
|
||||||
|
{ sourceLayer: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DataDependentGeoServerSourceId = Extract<
|
||||||
|
GeoServerSupplyLayerCatalogItem,
|
||||||
|
{ availability: "probe" }
|
||||||
|
>["id"];
|
||||||
|
|
||||||
|
export const DATA_DEPENDENT_GEOSERVER_SOURCE_IDS: DataDependentGeoServerSourceId[] = SUPPLY_LAYER_CATALOG.filter(
|
||||||
|
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "probe" }> =>
|
||||||
|
layer.sourceLayer !== undefined && layer.availability === "probe"
|
||||||
|
).map((layer) => layer.id);
|
||||||
|
|
||||||
|
export type GeoServerLayerAvailability = {
|
||||||
|
availableSourceIds: GeoServerWaterNetworkSourceId[];
|
||||||
|
emptySourceIds: DataDependentGeoServerSourceId[];
|
||||||
|
failedSourceIds: DataDependentGeoServerSourceId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SUPPLY_LAYER_CATALOG.filter(
|
||||||
|
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "required" }> =>
|
||||||
|
layer.sourceLayer !== undefined && layer.availability === "required"
|
||||||
|
).map((layer) => layer.id);
|
||||||
|
|
||||||
|
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
service: "WFS",
|
||||||
|
version: "2.0.0",
|
||||||
|
request: "GetFeature",
|
||||||
|
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
|
||||||
|
resultType: "hits"
|
||||||
|
});
|
||||||
|
|
||||||
|
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGeoServerLayerFeatureCount(
|
||||||
|
sourceId: DataDependentGeoServerSourceId,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
|
||||||
|
headers: { Accept: "application/xml, text/xml" },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
const payload = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
if (/Feature type\s+\S+\s+unknown/i.test(payload)) return 0;
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = payload.match(/\bnumberMatched\s*=\s*["'](\d+|unknown)["']/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测响应缺少 numberMatched`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1].toLowerCase() === "unknown") return 1;
|
||||||
|
|
||||||
|
const count = Number(match[1]);
|
||||||
|
if (!Number.isSafeInteger(count) || count < 0) {
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测返回无效数量`);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGeoServerLayerAvailability(
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<GeoServerLayerAvailability> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
|
||||||
|
sourceId,
|
||||||
|
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
const emptySourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
const failedSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const sourceId = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS[index];
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
failedSourceIds.push(sourceId);
|
||||||
|
} else if (result.value.count > 0) {
|
||||||
|
availableOptionalSourceIds.push(sourceId);
|
||||||
|
} else {
|
||||||
|
emptySourceIds.push(sourceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>([
|
||||||
|
...REQUIRED_GEOSERVER_SOURCE_IDS,
|
||||||
|
...availableOptionalSourceIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableSourceIds: GEOSERVER_WATER_NETWORK_SOURCE_IDS.filter((sourceId) =>
|
||||||
|
availableSourceIdSet.has(sourceId)
|
||||||
|
),
|
||||||
|
emptySourceIds,
|
||||||
|
failedSourceIds
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { waterNetworkBusinessLayers, waterNetworkLayers } from "./layers";
|
import {
|
||||||
|
filterWaterNetworkLayersBySourceIds,
|
||||||
|
waterNetworkBusinessLayers,
|
||||||
|
waterNetworkLayers
|
||||||
|
} from "./layers";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +38,14 @@ describe("supply network layer styling", () => {
|
|||||||
minzoom: 8.5,
|
minzoom: 8.5,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
|
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 });
|
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
|
||||||
|
|
||||||
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
|
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
|
||||||
@@ -47,7 +59,20 @@ describe("supply network layer styling", () => {
|
|||||||
const ids = waterNetworkLayers.map((layer) => layer.id);
|
const ids = waterNetworkLayers.map((layer) => layer.id);
|
||||||
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
|
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
|
||||||
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
||||||
expect(ids.at(-1)).toBe("reservoirs-hit");
|
expect(ids.at(-1)).toBe("tanks-hit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes every layer backed by an unavailable optional source", () => {
|
||||||
|
const filtered = filterWaterNetworkLayersBySourceIds(waterNetworkLayers, [
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "pumps")).toBe(false);
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "tanks")).toBe(false);
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "pipes")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
|||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SCADA_HIT_LAYER_ID } from "./scada";
|
import { SCADA_HIT_LAYER_ID } from "./scada";
|
||||||
import { SOURCE_LAYERS } from "./sources";
|
import {
|
||||||
|
SOURCE_LAYERS,
|
||||||
|
type GeoServerWaterNetworkSourceId
|
||||||
|
} from "./sources";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
createSupplyActiveOpacity,
|
createSupplyActiveOpacity,
|
||||||
@@ -22,6 +25,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
|
|||||||
"junctions-hit",
|
"junctions-hit",
|
||||||
"valves-hit",
|
"valves-hit",
|
||||||
"reservoirs-hit",
|
"reservoirs-hit",
|
||||||
|
"pumps-hit",
|
||||||
|
"tanks-hit",
|
||||||
SCADA_HIT_LAYER_ID
|
SCADA_HIT_LAYER_ID
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -208,6 +213,38 @@ layers.push(
|
|||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"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 +332,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
|
|||||||
const pointInteractions = [
|
const pointInteractions = [
|
||||||
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
|
["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],
|
["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;
|
] as const;
|
||||||
|
|
||||||
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
||||||
@@ -403,6 +442,30 @@ layers.push(
|
|||||||
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
|
||||||
"circle-opacity": 0
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,3 +478,13 @@ export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as L
|
|||||||
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
||||||
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
||||||
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
||||||
|
|
||||||
|
export function filterWaterNetworkLayersBySourceIds(
|
||||||
|
sourceLayers: StyleSpecification["layers"],
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||||
|
) {
|
||||||
|
const sourceIdSet = new Set<string>(sourceIds);
|
||||||
|
return sourceLayers.filter(
|
||||||
|
(layer) => !("source" in layer) || typeof layer.source !== "string" || sourceIdSet.has(layer.source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = {
|
|||||||
junction: "#47787B",
|
junction: "#47787B",
|
||||||
valve: "#6B7280",
|
valve: "#6B7280",
|
||||||
reservoir: "#195F84",
|
reservoir: "#195F84",
|
||||||
|
pump: "#326B87",
|
||||||
|
tank: "#4F6F7A",
|
||||||
scadaFlow: "#007A9E",
|
scadaFlow: "#007A9E",
|
||||||
scadaPressure: "#9A6700"
|
scadaPressure: "#9A6700"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,16 +19,37 @@ describe("workbench supply layer controls", () => {
|
|||||||
"valves",
|
"valves",
|
||||||
"reservoirs",
|
"reservoirs",
|
||||||
"scada",
|
"scada",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
"simulation"
|
"simulation"
|
||||||
]);
|
]);
|
||||||
expect(items.map((item) => item.id)).not.toContain("pumps");
|
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
|
||||||
expect(items.map((item) => item.id)).not.toContain("tanks");
|
"tjwater_next:pipes",
|
||||||
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
|
"tjwater_next:junctions",
|
||||||
"tjwater:geo_pipes_mat",
|
"tjwater_next:valves",
|
||||||
"tjwater:geo_junctions_mat",
|
"tjwater_next:reservoirs",
|
||||||
"tjwater:geo_valves",
|
"后端业务 API",
|
||||||
"tjwater:geo_reservoirs",
|
"tjwater_next:pumps",
|
||||||
"tjwater:geo_scada"
|
"tjwater_next:tanks"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits data-dependent controls when their GeoServer layers are empty", () => {
|
||||||
|
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, [
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs",
|
||||||
|
"scada"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.map((item) => item.id)).toEqual([
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs",
|
||||||
|
"scada",
|
||||||
|
"simulation"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +62,8 @@ describe("workbench supply layer controls", () => {
|
|||||||
"junction",
|
"junction",
|
||||||
"valve",
|
"valve",
|
||||||
"reservoir",
|
"reservoir",
|
||||||
|
"pump",
|
||||||
|
"tank",
|
||||||
"scada-flow",
|
"scada-flow",
|
||||||
"scada-pressure",
|
"scada-pressure",
|
||||||
"impact-area"
|
"impact-area"
|
||||||
@@ -50,6 +73,8 @@ describe("workbench supply layer controls", () => {
|
|||||||
label: "水库",
|
label: "水库",
|
||||||
imageSrc: "/map/reservoir.png"
|
imageSrc: "/map/reservoir.png"
|
||||||
});
|
});
|
||||||
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
|
||||||
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "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-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 === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
|
||||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
|
||||||
@@ -82,5 +107,12 @@ describe("workbench supply layer controls", () => {
|
|||||||
"reservoirs-selected",
|
"reservoirs-selected",
|
||||||
"reservoirs-hit"
|
"reservoirs-hit"
|
||||||
]);
|
]);
|
||||||
|
expect(getWorkbenchLayerIds(map, "pumps")).toEqual([
|
||||||
|
"pumps-symbol",
|
||||||
|
"pumps-hover",
|
||||||
|
"pumps-selected-outer",
|
||||||
|
"pumps-selected",
|
||||||
|
"pumps-hit"
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
|||||||
layer.id,
|
layer.id,
|
||||||
{
|
{
|
||||||
label: layer.label,
|
label: layer.label,
|
||||||
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
|
description: layer.id === "scada"
|
||||||
|
? "后端业务 API"
|
||||||
|
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
|
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
|
||||||
@@ -48,6 +50,8 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
|||||||
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
|
{ 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: "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: "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-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: "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" }
|
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
|
||||||
@@ -88,9 +92,12 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
|
export function createLayerControlItems(
|
||||||
|
layerVisibility: Record<string, boolean>,
|
||||||
|
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS
|
||||||
|
): MapLayerControlItem[] {
|
||||||
return [
|
return [
|
||||||
...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
|
...availableSourceIds.map((sourceId) => ({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
...SOURCE_CONTROL_LABELS[sourceId],
|
...SOURCE_CONTROL_LABELS[sourceId],
|
||||||
visible: layerVisibility[sourceId]
|
visible: layerVisibility[sourceId]
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ describe("map feature WFS query", () => {
|
|||||||
junctions: "id",
|
junctions: "id",
|
||||||
valves: "id",
|
valves: "id",
|
||||||
reservoirs: "id",
|
reservoirs: "id",
|
||||||
scada: "id",
|
|
||||||
pumps: "id",
|
pumps: "id",
|
||||||
tanks: "id"
|
tanks: "id"
|
||||||
});
|
});
|
||||||
@@ -23,18 +22,21 @@ describe("map feature WFS query", () => {
|
|||||||
.toBe("\"id\" IN ('J-1')");
|
.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'");
|
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
|
||||||
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["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("srsName")).toBe("EPSG:4326");
|
||||||
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the authoritative supply asset WFS layers", () => {
|
it("uses WFS only for authoritative supply asset layers", () => {
|
||||||
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
|
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("geo_reservoirs");
|
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs");
|
||||||
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
|
expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: "UNSUPPORTED_SOURCE"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("limits ids and permits full-network requests only for pipes", () => {
|
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(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
|
||||||
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
|
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: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 });
|
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)) }))
|
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
|
||||||
.toMatchObject({ ok: false, status: 400 });
|
.toMatchObject({ ok: false, status: 400 });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
|
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import type { FeatureCollection } from "geojson";
|
|||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import {
|
import {
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
isAvailableWaterNetworkSourceId,
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
type WaterNetworkSourceId
|
type GeoServerWaterNetworkSourceId
|
||||||
} from "./sources";
|
} from "./sources";
|
||||||
|
|
||||||
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
|
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
|
||||||
pipes: "id",
|
pipes: "id",
|
||||||
junctions: "id",
|
junctions: "id",
|
||||||
valves: "id",
|
valves: "id",
|
||||||
reservoirs: "id",
|
reservoirs: "id",
|
||||||
scada: "id",
|
|
||||||
pumps: "id",
|
pumps: "id",
|
||||||
tanks: "id"
|
tanks: "id"
|
||||||
};
|
};
|
||||||
@@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128;
|
|||||||
export const MAX_PIPE_FEATURES = 5000;
|
export const MAX_PIPE_FEATURES = 5000;
|
||||||
|
|
||||||
export type MapFeatureQuery = {
|
export type MapFeatureQuery = {
|
||||||
sourceId: WaterNetworkSourceId;
|
sourceId: GeoServerWaterNetworkSourceId;
|
||||||
featureIds?: string[];
|
featureIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
|
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 };
|
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
|
value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,19 +56,19 @@ describe("SCADA analysis overlay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("validates one to one hundred unique IDs and fixed levels", () => {
|
it("validates one to one hundred unique IDs and fixed levels", () => {
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
|
expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([
|
||||||
{ sensor_id: "MP01", level: "high" }
|
{ device_id: "MP01", level: "high" }
|
||||||
]);
|
]);
|
||||||
expect(parseScadaAnalysisItems([])).toBeNull();
|
expect(parseScadaAnalysisItems([])).toBeNull();
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
|
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull();
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).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, [
|
const result = createScadaAnalysisCollection(scadaCollection, [
|
||||||
{ sensor_id: "MP01", level: "high" },
|
{ device_id: "MP01", level: "high" },
|
||||||
{ sensor_id: "MP02", level: "medium" },
|
{ device_id: "MP02", level: "medium" },
|
||||||
{ sensor_id: "MP404", level: "low" }
|
{ device_id: "MP404", level: "low" }
|
||||||
]);
|
]);
|
||||||
expect(result.result).toEqual({
|
expect(result.result).toEqual({
|
||||||
rendered_ids: ["MP01", "MP02"],
|
rendered_ids: ["MP01", "MP02"],
|
||||||
@@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => {
|
|||||||
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
|
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
|
||||||
});
|
});
|
||||||
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
|
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
|
||||||
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
|
expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
|
||||||
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
|
expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => {
|
|||||||
const scadaCollection: FeatureCollection = {
|
const scadaCollection: FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [
|
features: [
|
||||||
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
|
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
|
||||||
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
|
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = {
|
|||||||
|
|
||||||
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
|
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
|
||||||
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
|
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<ScadaAnalysisLevel, number>;
|
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
|
||||||
export type ScadaAnalysisRenderResult = {
|
export type ScadaAnalysisRenderResult = {
|
||||||
rendered_ids: string[];
|
rendered_ids: string[];
|
||||||
@@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
|
|||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
||||||
const keys = Object.keys(entry);
|
const keys = Object.keys(entry);
|
||||||
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
|
if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
|
||||||
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
|
const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
|
||||||
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
|
const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
|
||||||
if (
|
if (
|
||||||
!sensorId ||
|
!deviceId ||
|
||||||
sensorId.length > 128 ||
|
deviceId.length > 128 ||
|
||||||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
|
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
|
||||||
seen.has(sensorId)
|
seen.has(deviceId)
|
||||||
) return null;
|
) return null;
|
||||||
seen.add(sensorId);
|
seen.add(deviceId);
|
||||||
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
|
items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -164,13 +164,13 @@ export function createScadaAnalysisCollection(
|
|||||||
collection: FeatureCollection,
|
collection: FeatureCollection,
|
||||||
items: ScadaAnalysisItem[]
|
items: ScadaAnalysisItem[]
|
||||||
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
|
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
|
||||||
const featuresBySensorId = new Map<string, Feature<Point>>();
|
const featuresByDeviceId = new Map<string, Feature<Point>>();
|
||||||
collection.features.forEach((feature) => {
|
collection.features.forEach((feature) => {
|
||||||
const sensorId = typeof feature.properties?.sensor_id === "string"
|
const deviceId = typeof feature.properties?.device_id === "string"
|
||||||
? feature.properties.sensor_id.trim()
|
? feature.properties.device_id.trim()
|
||||||
: "";
|
: "";
|
||||||
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
|
if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
|
||||||
featuresBySensorId.set(sensorId, feature as Feature<Point>);
|
featuresByDeviceId.set(deviceId, feature as Feature<Point>);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,26 +179,26 @@ export function createScadaAnalysisCollection(
|
|||||||
const missingIds: string[] = [];
|
const missingIds: string[] = [];
|
||||||
const features: Array<Feature<Point>> = [];
|
const features: Array<Feature<Point>> = [];
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const feature = featuresBySensorId.get(item.sensor_id);
|
const feature = featuresByDeviceId.get(item.device_id);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
missingIds.push(item.sensor_id);
|
missingIds.push(item.device_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
|
const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
|
||||||
if (!trustedSensorId) {
|
if (!trustedDeviceId) {
|
||||||
missingIds.push(item.sensor_id);
|
missingIds.push(item.device_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderedIds.push(trustedSensorId);
|
renderedIds.push(trustedDeviceId);
|
||||||
levelCounts[item.level] += 1;
|
levelCounts[item.level] += 1;
|
||||||
features.push({
|
features.push({
|
||||||
...feature,
|
...feature,
|
||||||
properties: {
|
properties: {
|
||||||
...feature.properties,
|
...feature.properties,
|
||||||
sensor_id: trustedSensorId,
|
device_id: trustedDeviceId,
|
||||||
analysis_level: item.level,
|
analysis_level: item.level,
|
||||||
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[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]
|
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
|||||||
describe("SCADA map presentation", () => {
|
describe("SCADA map presentation", () => {
|
||||||
it("maps the authoritative device types and falls back to unknown", () => {
|
it("maps the authoritative device types and falls back to unknown", () => {
|
||||||
expect(SCADA_ICON_EXPRESSION).toEqual([
|
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,
|
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
||||||
"pressure", SCADA_IMAGE_IDS.pressure,
|
"pressure", SCADA_IMAGE_IDS.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);
|
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", () => {
|
it("keeps the icon and selected ring consistent with junction selection", () => {
|
||||||
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
||||||
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
|
|
||||||
export const SCADA_SOURCE_ID = "scada";
|
export const SCADA_SOURCE_ID = "scada";
|
||||||
export const SCADA_SOURCE_LAYER = "geo_scada";
|
|
||||||
export const SCADA_HIT_LAYER_ID = "scada-hit";
|
export const SCADA_HIT_LAYER_ID = "scada-hit";
|
||||||
|
|
||||||
export const SCADA_ICON_PATHS = {
|
export const SCADA_ICON_PATHS = {
|
||||||
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
|
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,
|
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
||||||
"pressure", SCADA_IMAGE_IDS.pressure,
|
"pressure", SCADA_IMAGE_IDS.pressure,
|
||||||
SCADA_IMAGE_IDS.pressure
|
SCADA_IMAGE_IDS.pressure
|
||||||
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
|||||||
];
|
];
|
||||||
|
|
||||||
const scadaColor: 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,
|
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
|
||||||
MAP_STYLE_TOKENS.supply.scadaPressure
|
MAP_STYLE_TOKENS.supply.scadaPressure
|
||||||
];
|
];
|
||||||
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaCasingLayer: Layer = {
|
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: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
@@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaFillLayer: 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: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
||||||
"circle-color": createSupplyStateColor(scadaColor),
|
"circle-color": createSupplyStateColor(scadaColor),
|
||||||
@@ -71,13 +70,13 @@ const scadaFillLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaSymbolLayer: 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 },
|
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 }
|
paint: { "icon-opacity": supplyIconOpacityExpression }
|
||||||
};
|
};
|
||||||
|
|
||||||
const scadaFallbackLayer: Layer = {
|
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: {
|
paint: {
|
||||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
|
"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,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
@@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const scadaInteractionLayers: 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-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, "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-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, "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-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 = {
|
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 }
|
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
SUPPLY_LAYER_CATALOG,
|
SUPPLY_LAYER_CATALOG,
|
||||||
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
WATER_NETWORK_SOURCE_IDS
|
WATER_NETWORK_SOURCE_IDS
|
||||||
@@ -10,46 +12,65 @@ import {
|
|||||||
describe("createWaterNetworkSources", () => {
|
describe("createWaterNetworkSources", () => {
|
||||||
it("catalogs published and pre-registered supply system layers", () => {
|
it("catalogs published and pre-registered supply system layers", () => {
|
||||||
expect(SOURCE_LAYERS).toEqual({
|
expect(SOURCE_LAYERS).toEqual({
|
||||||
pipes: "geo_pipes_mat",
|
pipes: "pipes",
|
||||||
junctions: "geo_junctions_mat",
|
junctions: "junctions",
|
||||||
valves: "geo_valves",
|
valves: "valves",
|
||||||
reservoirs: "geo_reservoirs",
|
reservoirs: "reservoirs",
|
||||||
scada: "geo_scada",
|
pumps: "pumps",
|
||||||
pumps: "geo_pumps",
|
tanks: "tanks"
|
||||||
tanks: "geo_tanks"
|
|
||||||
});
|
});
|
||||||
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "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([
|
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
|
||||||
["pipes", true],
|
["pipes", true, "required"],
|
||||||
["junctions", true],
|
["junctions", true, "required"],
|
||||||
["valves", true],
|
["valves", true, "probe"],
|
||||||
["reservoirs", true],
|
["reservoirs", true, "probe"],
|
||||||
["scada", true],
|
["scada", true, "required"],
|
||||||
["pumps", false],
|
["pumps", true, "probe"],
|
||||||
["tanks", false]
|
["tanks", true, "probe"]
|
||||||
]);
|
]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
sourceLayer: "geo_valves"
|
sourceLayer: "valves"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads only available supply system layers from GeoServer WMTS", () => {
|
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({
|
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
|
||||||
label: "水库",
|
label: "水库",
|
||||||
icon: "reservoir"
|
icon: "reservoir"
|
||||||
});
|
});
|
||||||
|
|
||||||
const sources = createWaterNetworkSources();
|
const sources = createWaterNetworkSources();
|
||||||
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
|
expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
|
||||||
expect(sources[sourceId].tiles).toEqual([
|
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].promoteId).toBe("id");
|
||||||
|
expect(sources[sourceId].bounds).toEqual([
|
||||||
|
121.35162408429075,
|
||||||
|
30.81049932304578,
|
||||||
|
121.77248479490075,
|
||||||
|
31.007207809222017
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
expect("pumps" in sources).toBe(false);
|
expect("pumps" in sources).toBe(true);
|
||||||
expect("tanks" in sources).toBe(false);
|
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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only the requested GeoServer vector sources", () => {
|
||||||
|
const sources = createWaterNetworkSources(["pipes", "junctions"]);
|
||||||
|
|
||||||
|
expect(Object.keys(sources)).toEqual(["pipes", "junctions"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ import {
|
|||||||
SCADA_ANALYSIS_SOURCE_ID,
|
SCADA_ANALYSIS_SOURCE_ID,
|
||||||
createEmptyScadaAnalysisCollection
|
createEmptyScadaAnalysisCollection
|
||||||
} from "./scada-analysis";
|
} from "./scada-analysis";
|
||||||
|
import { SCADA_SOURCE_ID } from "./scada";
|
||||||
|
|
||||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
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;
|
} as const;
|
||||||
|
|
||||||
export const SOURCE_LAYERS = {
|
export const SOURCE_LAYERS = {
|
||||||
pipes: "geo_pipes_mat",
|
pipes: "pipes",
|
||||||
junctions: "geo_junctions_mat",
|
junctions: "junctions",
|
||||||
valves: "geo_valves",
|
valves: "valves",
|
||||||
reservoirs: "geo_reservoirs",
|
reservoirs: "reservoirs",
|
||||||
scada: "geo_scada",
|
pumps: "pumps",
|
||||||
pumps: "geo_pumps",
|
tanks: "tanks"
|
||||||
tanks: "geo_tanks"
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const WATER_NETWORK_SOURCE_IDS = [
|
export const WATER_NETWORK_SOURCE_IDS = [
|
||||||
@@ -35,9 +36,10 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
|
|||||||
|
|
||||||
export type SupplyLayerCatalogItem = {
|
export type SupplyLayerCatalogItem = {
|
||||||
id: WaterNetworkSourceId;
|
id: WaterNetworkSourceId;
|
||||||
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId];
|
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
|
||||||
geometry: "line" | "point";
|
geometry: "line" | "point";
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
availability: "required" | "probe";
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.pipes,
|
sourceLayer: SOURCE_LAYERS.pipes,
|
||||||
geometry: "line",
|
geometry: "line",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "管线",
|
label: "管线",
|
||||||
icon: "pipe"
|
icon: "pipe"
|
||||||
},
|
},
|
||||||
@@ -56,6 +59,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.junctions,
|
sourceLayer: SOURCE_LAYERS.junctions,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "节点",
|
label: "节点",
|
||||||
icon: "junction"
|
icon: "junction"
|
||||||
},
|
},
|
||||||
@@ -64,6 +68,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.valves,
|
sourceLayer: SOURCE_LAYERS.valves,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "阀门",
|
label: "阀门",
|
||||||
icon: "valve"
|
icon: "valve"
|
||||||
},
|
},
|
||||||
@@ -72,14 +77,16 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.reservoirs,
|
sourceLayer: SOURCE_LAYERS.reservoirs,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水库",
|
label: "水库",
|
||||||
icon: "reservoir"
|
icon: "reservoir"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scada",
|
id: "scada",
|
||||||
sourceLayer: SOURCE_LAYERS.scada,
|
sourceLayer: undefined,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "SCADA",
|
label: "SCADA",
|
||||||
icon: "scada-pressure"
|
icon: "scada-pressure"
|
||||||
},
|
},
|
||||||
@@ -87,7 +94,8 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
id: "pumps",
|
id: "pumps",
|
||||||
sourceLayer: SOURCE_LAYERS.pumps,
|
sourceLayer: SOURCE_LAYERS.pumps,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: false,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水泵",
|
label: "水泵",
|
||||||
icon: "pump"
|
icon: "pump"
|
||||||
},
|
},
|
||||||
@@ -95,7 +103,8 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
id: "tanks",
|
id: "tanks",
|
||||||
sourceLayer: SOURCE_LAYERS.tanks,
|
sourceLayer: SOURCE_LAYERS.tanks,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: false,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水箱",
|
label: "水箱",
|
||||||
icon: "tank"
|
icon: "tank"
|
||||||
}
|
}
|
||||||
@@ -103,12 +112,14 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
|
|
||||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
||||||
(layer) => layer.available
|
(layer) => layer.available
|
||||||
).map((layer) => layer.id) as Extract<
|
).map((layer) => layer.id) as WaterNetworkSourceId[];
|
||||||
WaterNetworkSourceId,
|
|
||||||
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
|
|
||||||
>[];
|
|
||||||
|
|
||||||
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
||||||
|
export type GeoServerWaterNetworkSourceId = Exclude<AvailableWaterNetworkSourceId, "scada">;
|
||||||
|
|
||||||
|
export const GEOSERVER_WATER_NETWORK_SOURCE_IDS = AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
|
||||||
|
(sourceId): sourceId is GeoServerWaterNetworkSourceId => sourceId !== "scada"
|
||||||
|
);
|
||||||
|
|
||||||
export function isAvailableWaterNetworkSourceId(
|
export function isAvailableWaterNetworkSourceId(
|
||||||
value: string
|
value: string
|
||||||
@@ -116,13 +127,22 @@ export function isAvailableWaterNetworkSourceId(
|
|||||||
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWaterNetworkSources() {
|
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
|
||||||
|
export function createWaterNetworkSources(
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||||
|
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||||
|
export function createWaterNetworkSources(
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
|
||||||
|
) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
|
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
||||||
layer.id,
|
layer.available &&
|
||||||
createGeoServerVectorSource(layer.sourceLayer)
|
layer.sourceLayer !== undefined &&
|
||||||
])
|
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
|
||||||
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
|
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGeoServerVectorSource(
|
function createGeoServerVectorSource(
|
||||||
@@ -135,12 +155,18 @@ function createGeoServerVectorSource(
|
|||||||
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||||
],
|
],
|
||||||
minzoom: 0,
|
minzoom: 0,
|
||||||
maxzoom: 24
|
maxzoom: 24,
|
||||||
|
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||||
const sources: StyleSpecification["sources"] = {
|
const sources: StyleSpecification["sources"] = {
|
||||||
|
[SCADA_SOURCE_ID]: {
|
||||||
|
type: "geojson",
|
||||||
|
promoteId: "device_id",
|
||||||
|
data: { type: "FeatureCollection", features: [] }
|
||||||
|
},
|
||||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: createEmptyScadaAnalysisCollection()
|
data: createEmptyScadaAnalysisCollection()
|
||||||
|
|||||||
@@ -21,6 +21,192 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a stale highlight without touching a removed source", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
controller.clearHighlight();
|
||||||
|
|
||||||
|
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().target).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a resolved highlight after its source has been removed", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
|
||||||
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
|
||||||
|
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().target).toBeNull();
|
||||||
|
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not move to a cached feature after its source has been removed", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
const target = { sourceId: "junctions" as const, featureId: "J-1" };
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
|
||||||
|
expect(map.easeTo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops cached features and presentation state when a dynamic source disappears", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
map.getLayer.mockReturnValue({} as never);
|
||||||
|
let collection = pointCollection;
|
||||||
|
const fetchFeatures = vi.fn(async () => collection);
|
||||||
|
const controller = new WorkbenchMapController({
|
||||||
|
getMap: () => map as unknown as MapLibreMap,
|
||||||
|
isReady: () => true,
|
||||||
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
fetchFeatures
|
||||||
|
});
|
||||||
|
const target = { sourceId: "valves" as const, featureId: "V-1" };
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 });
|
||||||
|
await controller.showValueLabel({
|
||||||
|
sourceId: "valves",
|
||||||
|
property: "elevation",
|
||||||
|
precision: 1,
|
||||||
|
showValue: true,
|
||||||
|
showId: false,
|
||||||
|
textSize: 12,
|
||||||
|
textColor: "#0F172A"
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
|
||||||
|
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({
|
||||||
|
target: null,
|
||||||
|
valueLabels: {},
|
||||||
|
styledGroups: []
|
||||||
|
});
|
||||||
|
collection = {
|
||||||
|
...pointCollection,
|
||||||
|
features: [{
|
||||||
|
...pointCollection.features[0],
|
||||||
|
geometry: { type: "Point", coordinates: [121.9, 29.1] }
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
map.easeTo.mockClear();
|
||||||
|
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
|
||||||
|
expect(fetchFeatures).toHaveBeenCalledTimes(2);
|
||||||
|
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
let resolveFeatures = (_collection: FeatureCollection) => {};
|
||||||
|
const featureResponse = new Promise<FeatureCollection>((resolve) => {
|
||||||
|
resolveFeatures = resolve;
|
||||||
|
});
|
||||||
|
const controller = new WorkbenchMapController({
|
||||||
|
getMap: () => map as unknown as MapLibreMap,
|
||||||
|
isReady: () => true,
|
||||||
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
fetchFeatures: async () => featureResponse
|
||||||
|
});
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
|
||||||
|
|
||||||
|
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
|
||||||
|
resolveFeatures(pointCollection);
|
||||||
|
await locating;
|
||||||
|
|
||||||
|
expect(map.easeTo).not.toHaveBeenCalled();
|
||||||
|
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().target).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newer cached target when an older dynamic-source request resolves", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
let resolveDynamicFeatures = (_collection: FeatureCollection) => {};
|
||||||
|
const dynamicFeatureResponse = new Promise<FeatureCollection>((resolve) => {
|
||||||
|
resolveDynamicFeatures = resolve;
|
||||||
|
});
|
||||||
|
const fetchFeatures = vi.fn(async (sourceId: string) =>
|
||||||
|
sourceId === "valves" ? dynamicFeatureResponse : pointCollection
|
||||||
|
);
|
||||||
|
const controller = new WorkbenchMapController({
|
||||||
|
getMap: () => map as unknown as MapLibreMap,
|
||||||
|
isReady: () => true,
|
||||||
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
fetchFeatures
|
||||||
|
});
|
||||||
|
const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" };
|
||||||
|
await controller.locateAndHighlight(cachedTarget);
|
||||||
|
|
||||||
|
const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
|
||||||
|
await controller.locateAndHighlight(cachedTarget);
|
||||||
|
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
|
||||||
|
resolveDynamicFeatures(pointCollection);
|
||||||
|
await locatingDynamicTarget;
|
||||||
|
|
||||||
|
expect(map.easeTo).toHaveBeenCalledTimes(2);
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore a target after its in-flight request is cleared", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
let resolveFeatures = (_collection: FeatureCollection) => {};
|
||||||
|
const featureResponse = new Promise<FeatureCollection>((resolve) => {
|
||||||
|
resolveFeatures = resolve;
|
||||||
|
});
|
||||||
|
const controller = new WorkbenchMapController({
|
||||||
|
getMap: () => map as unknown as MapLibreMap,
|
||||||
|
isReady: () => true,
|
||||||
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
fetchFeatures: async () => featureResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
|
||||||
|
controller.clearHighlight();
|
||||||
|
resolveFeatures(pointCollection);
|
||||||
|
await locating;
|
||||||
|
|
||||||
|
expect(map.easeTo).not.toHaveBeenCalled();
|
||||||
|
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unavailable target when its source disappears in flight", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
let resolveFeatures = (_collection: FeatureCollection) => {};
|
||||||
|
const featureResponse = new Promise<FeatureCollection>((resolve) => {
|
||||||
|
resolveFeatures = resolve;
|
||||||
|
});
|
||||||
|
const controller = new WorkbenchMapController({
|
||||||
|
getMap: () => map as unknown as MapLibreMap,
|
||||||
|
isReady: () => true,
|
||||||
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
fetchFeatures: async () => featureResponse
|
||||||
|
});
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
|
||||||
|
|
||||||
|
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
|
||||||
|
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
|
||||||
|
resolveFeatures(pointCollection);
|
||||||
|
await locating;
|
||||||
|
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({
|
||||||
|
target: null,
|
||||||
|
pending: false,
|
||||||
|
errorCode: "WFS_UNAVAILABLE"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fits line bounds and uses responsive workbench padding", async () => {
|
it("fits line bounds and uses responsive workbench padding", async () => {
|
||||||
const map = createMap();
|
const map = createMap();
|
||||||
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
||||||
@@ -56,7 +242,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
|
||||||
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
|
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("request")).toBe("GetFeature");
|
||||||
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
|
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
|
||||||
expect(map.setFeatureState).toHaveBeenCalled();
|
expect(map.setFeatureState).toHaveBeenCalled();
|
||||||
@@ -81,7 +267,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
|
|
||||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat"
|
"source-layer": "pipes"
|
||||||
});
|
});
|
||||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -96,7 +282,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
sourceLayer: "geo_pipes_mat",
|
sourceLayer: "pipes",
|
||||||
id: "P-1"
|
id: "P-1"
|
||||||
}),
|
}),
|
||||||
{ flowDirection: -1 }
|
{ flowDirection: -1 }
|
||||||
@@ -251,13 +437,13 @@ describe("WorkbenchMapController", () => {
|
|||||||
{
|
{
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
geometry: { type: "Point", coordinates: [120.7, 28] },
|
||||||
properties: { sensor_id: "MP01" }
|
properties: { device_id: "MP01" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
|
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
rendered_ids: ["MP01"],
|
rendered_ids: ["MP01"],
|
||||||
fitted: true
|
fitted: true
|
||||||
@@ -281,7 +467,7 @@ function createMap() {
|
|||||||
const images = new Set<string>();
|
const images = new Set<string>();
|
||||||
return {
|
return {
|
||||||
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
||||||
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
|
getSource: vi.fn((_id: string): unknown => ({})), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
|
||||||
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
||||||
hasImage: vi.fn((id: string) => images.has(id)),
|
hasImage: vi.fn((id: string) => images.has(id)),
|
||||||
addImage: vi.fn((id: string) => {
|
addImage: vi.fn((id: string) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
normalizeMapFeatureCollection,
|
normalizeMapFeatureCollection,
|
||||||
parseMapFeatureQuery
|
parseMapFeatureQuery
|
||||||
} from "./map-feature-query";
|
} from "./map-feature-query";
|
||||||
|
import { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS } from "./geoserver-layer-availability";
|
||||||
import {
|
import {
|
||||||
applyValueLabelStyle,
|
applyValueLabelStyle,
|
||||||
clearValueLabels,
|
clearValueLabels,
|
||||||
@@ -47,6 +48,7 @@ export type WorkbenchMapErrorCode =
|
|||||||
| "MAP_NOT_READY"
|
| "MAP_NOT_READY"
|
||||||
| "FEATURE_NOT_FOUND"
|
| "FEATURE_NOT_FOUND"
|
||||||
| "WFS_UNAVAILABLE"
|
| "WFS_UNAVAILABLE"
|
||||||
|
| "SCADA_API_UNAVAILABLE"
|
||||||
| "SCADA_FEATURES_NOT_FOUND"
|
| "SCADA_FEATURES_NOT_FOUND"
|
||||||
| "ACTION_SUPERSEDED"
|
| "ACTION_SUPERSEDED"
|
||||||
| "FLOW_UNAVAILABLE"
|
| "FLOW_UNAVAILABLE"
|
||||||
@@ -88,6 +90,7 @@ export type WorkbenchMapControllerOptions = {
|
|||||||
isReady: () => boolean;
|
isReady: () => boolean;
|
||||||
getPadding: () => PaddingOptions;
|
getPadding: () => PaddingOptions;
|
||||||
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
||||||
|
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
||||||
reducedMotion?: () => boolean;
|
reducedMotion?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +111,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||||
private flowUpdateRevision = 0;
|
private flowUpdateRevision = 0;
|
||||||
private scadaAnalysisRevision = 0;
|
private scadaAnalysisRevision = 0;
|
||||||
|
private targetRequestRevision = 0;
|
||||||
|
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||||
|
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
||||||
|
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
|
||||||
|
|
||||||
constructor(private readonly options: WorkbenchMapControllerOptions) {}
|
constructor(private readonly options: WorkbenchMapControllerOptions) {}
|
||||||
|
|
||||||
@@ -119,15 +126,21 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
getSnapshot = () => this.state;
|
getSnapshot = () => this.state;
|
||||||
|
|
||||||
async zoomToFeature(target: FeatureTarget) {
|
async zoomToFeature(target: FeatureTarget) {
|
||||||
const feature = await this.resolveTarget(target);
|
const requestRevision = this.beginTargetCommand();
|
||||||
const map = this.requireMap();
|
if (!this.requireTargetMap(target)) return;
|
||||||
|
const feature = await this.resolveTarget(target, requestRevision);
|
||||||
|
if (requestRevision !== this.targetRequestRevision) return;
|
||||||
|
const map = this.requireTargetMap(target);
|
||||||
if (!feature || !map) return;
|
if (!feature || !map) return;
|
||||||
this.moveCameraToFeature(map, feature);
|
this.moveCameraToFeature(map, feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async locateAndHighlight(target: FeatureTarget) {
|
async locateAndHighlight(target: FeatureTarget) {
|
||||||
const feature = await this.resolveTarget(target);
|
const requestRevision = this.beginTargetCommand();
|
||||||
const map = this.requireMap();
|
if (!this.requireTargetMap(target)) return;
|
||||||
|
const feature = await this.resolveTarget(target, requestRevision);
|
||||||
|
if (requestRevision !== this.targetRequestRevision) return;
|
||||||
|
const map = this.requireTargetMap(target);
|
||||||
if (!feature || !map) return;
|
if (!feature || !map) return;
|
||||||
if (!this.moveCameraToFeature(map, feature)) return;
|
if (!this.moveCameraToFeature(map, feature)) return;
|
||||||
this.setHighlight(target);
|
this.setHighlight(target);
|
||||||
@@ -163,19 +176,90 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async highlight(target: FeatureTarget) {
|
async highlight(target: FeatureTarget) {
|
||||||
const feature = await this.resolveTarget(target);
|
const requestRevision = this.beginTargetCommand();
|
||||||
|
if (!this.requireTargetMap(target)) return;
|
||||||
|
const feature = await this.resolveTarget(target, requestRevision);
|
||||||
|
if (requestRevision !== this.targetRequestRevision) return;
|
||||||
if (feature) this.setHighlight(target);
|
if (feature) this.setHighlight(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHighlight = () => {
|
clearHighlight = () => {
|
||||||
|
this.beginTargetCommand();
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map && this.highlightedTarget) {
|
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
}
|
}
|
||||||
this.highlightedTarget = null;
|
this.highlightedTarget = null;
|
||||||
this.patchState({ target: null, errorCode: null });
|
this.patchState({ target: null, errorCode: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
|
||||||
|
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
|
||||||
|
if (this.availableSourceIds) {
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||||
|
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
|
||||||
|
this.sourceAvailabilityGenerations.set(
|
||||||
|
sourceId,
|
||||||
|
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.availableSourceIds = availableSourceIds;
|
||||||
|
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||||
|
if (availableSourceIds.has(sourceId)) return;
|
||||||
|
const cachePrefix = `${sourceId}:`;
|
||||||
|
for (const key of this.cachedFeatures.keys()) {
|
||||||
|
if (key.startsWith(cachePrefix)) this.cachedFeatures.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetUnavailable = Boolean(
|
||||||
|
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
|
||||||
|
);
|
||||||
|
const pendingTargetUnavailable = Boolean(
|
||||||
|
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
|
||||||
|
);
|
||||||
|
if (pendingTargetUnavailable) {
|
||||||
|
this.targetRequestRevision += 1;
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
|
}
|
||||||
|
if (targetUnavailable && this.highlightedTarget) {
|
||||||
|
const map = this.options.getMap();
|
||||||
|
if (map?.getSource(this.highlightedTarget.sourceId)) {
|
||||||
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
|
}
|
||||||
|
this.highlightedTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueLabelEntries = Object.entries(this.state.valueLabels);
|
||||||
|
const availableValueLabelEntries = valueLabelEntries.filter(([sourceId]) =>
|
||||||
|
availableSourceIds.has(sourceId as WaterNetworkSourceId)
|
||||||
|
);
|
||||||
|
const valueLabelsChanged = availableValueLabelEntries.length !== valueLabelEntries.length;
|
||||||
|
const styledGroups = this.state.styledGroups.filter((groupId) =>
|
||||||
|
availableSourceIds.has(groupId)
|
||||||
|
);
|
||||||
|
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
|
||||||
|
if (
|
||||||
|
!targetUnavailable &&
|
||||||
|
!pendingTargetUnavailable &&
|
||||||
|
!valueLabelsChanged &&
|
||||||
|
!styledGroupsChanged
|
||||||
|
) return;
|
||||||
|
|
||||||
|
this.patchState({
|
||||||
|
target: targetUnavailable ? null : this.state.target,
|
||||||
|
pending: pendingTargetUnavailable ? false : this.state.pending,
|
||||||
|
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
|
||||||
|
valueLabels: valueLabelsChanged
|
||||||
|
? Object.fromEntries(availableValueLabelEntries) as WorkbenchMapControllerState["valueLabels"]
|
||||||
|
: this.state.valueLabels,
|
||||||
|
styledGroups
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async setFlowVisible(visible: boolean) {
|
async setFlowVisible(visible: boolean) {
|
||||||
const map = this.requireMap();
|
const map = this.requireMap();
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -320,16 +404,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
try {
|
try {
|
||||||
sourceCollection = await this.fetchFeatures(
|
sourceCollection = await this.fetchFeatures(
|
||||||
"scada",
|
"scada",
|
||||||
items.map((item) => item.sensor_id),
|
items.map((item) => item.device_id),
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
if (revision === this.scadaAnalysisRevision) {
|
if (revision === this.scadaAnalysisRevision) {
|
||||||
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
|
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
revision === this.scadaAnalysisRevision
|
revision === this.scadaAnalysisRevision
|
||||||
? "WFS_UNAVAILABLE"
|
? "SCADA_API_UNAVAILABLE"
|
||||||
: "ACTION_SUPERSEDED"
|
: "ACTION_SUPERSEDED"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -440,36 +524,66 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.flowUpdateRevision += 1;
|
this.flowUpdateRevision += 1;
|
||||||
|
this.targetRequestRevision += 1;
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map) hideFlowOverlay(map, this.flowState);
|
if (map) hideFlowOverlay(map, this.flowState);
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveTarget(target: FeatureTarget) {
|
private beginTargetCommand() {
|
||||||
|
const requestRevision = ++this.targetRequestRevision;
|
||||||
|
if (this.pendingTargetSourceId) {
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
|
this.patchState({ pending: false });
|
||||||
|
}
|
||||||
|
return requestRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
|
||||||
const map = this.requireMap();
|
const map = this.requireMap();
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
const key = `${target.sourceId}:${target.featureId}`;
|
const key = `${target.sourceId}:${target.featureId}`;
|
||||||
const cached = this.cachedFeatures.get(key);
|
const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
|
||||||
|
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
|
||||||
|
);
|
||||||
|
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
|
||||||
|
this.pendingTargetSourceId = target.sourceId;
|
||||||
this.patchState({ pending: true, errorCode: null });
|
this.patchState({ pending: true, errorCode: null });
|
||||||
try {
|
try {
|
||||||
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
|
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
|
||||||
|
if (
|
||||||
|
requestRevision !== this.targetRequestRevision ||
|
||||||
|
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
|
||||||
|
) return null;
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
const feature = collection.features[0];
|
const feature = collection.features[0];
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.cachedFeatures.set(key, feature);
|
if (!dataDependent) this.cachedFeatures.set(key, feature);
|
||||||
this.patchState({ pending: false });
|
this.patchState({ pending: false });
|
||||||
return feature;
|
return feature;
|
||||||
} catch {
|
} catch {
|
||||||
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
|
if (requestRevision !== this.targetRequestRevision) return null;
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
|
this.patchState({
|
||||||
|
pending: false,
|
||||||
|
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||||
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
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 });
|
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||||
if (!parsed.ok) throw new Error(parsed.code);
|
if (!parsed.ok) throw new Error(parsed.code);
|
||||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||||
@@ -484,9 +598,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setHighlight(target: FeatureTarget) {
|
private setHighlight(target: FeatureTarget) {
|
||||||
const map = this.requireMap();
|
const map = this.requireTargetMap(target);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
if (this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||||
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
|
}
|
||||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||||
this.highlightedTarget = target;
|
this.highlightedTarget = target;
|
||||||
this.patchState({ target, errorCode: null });
|
this.patchState({ target, errorCode: null });
|
||||||
@@ -501,6 +617,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requireTargetMap(target: FeatureTarget) {
|
||||||
|
const map = this.requireMap();
|
||||||
|
if (!map) return null;
|
||||||
|
if (!map.getSource(target.sourceId)) {
|
||||||
|
this.setError("FEATURE_NOT_FOUND");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
private setError(errorCode: WorkbenchMapErrorCode) {
|
private setError(errorCode: WorkbenchMapErrorCode) {
|
||||||
this.patchState({ errorCode });
|
this.patchState({ errorCode });
|
||||||
}
|
}
|
||||||
@@ -516,7 +642,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toFeatureStateTarget(target: FeatureTarget) {
|
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[] {
|
function collectCoordinates(geometry: Geometry | null): Position[] {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ScheduledConditionItem } from "../types";
|
||||||
|
import {
|
||||||
|
canCenterTimelineBesideMapScale,
|
||||||
|
clusterOperationalEvents,
|
||||||
|
createOperationalEvents,
|
||||||
|
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||||
|
filterOperationalEvents,
|
||||||
|
getOperationalEventCategory,
|
||||||
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
|
resolveTimelineScaleLayout
|
||||||
|
} from "./operational-timeline-model";
|
||||||
|
import type { OperationalEvent } from "./operational-timeline-model";
|
||||||
|
|
||||||
|
const workOrder: ScheduledConditionItem = {
|
||||||
|
id: "work-1",
|
||||||
|
kind: "work_order",
|
||||||
|
code: "WO-1",
|
||||||
|
scheduledAt: "2026-08-19T10:00:00.000Z",
|
||||||
|
title: "阀门调整",
|
||||||
|
summary: "调整阀门",
|
||||||
|
status: "running",
|
||||||
|
riskLevel: "attention",
|
||||||
|
updatedAt: 1,
|
||||||
|
durationMinutes: 40,
|
||||||
|
source: "调度方案",
|
||||||
|
location: "边界阀门 BV-07",
|
||||||
|
dispatcher: "调度",
|
||||||
|
assignee: "班组",
|
||||||
|
priority: "urgent",
|
||||||
|
replyWindowMinutes: 20,
|
||||||
|
stages: [],
|
||||||
|
replyRequirements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("operational timeline model", () => {
|
||||||
|
it("does not reserve workspace layout space for the floating timeline", () => {
|
||||||
|
expect(OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates linked plan and actual events for active work orders", () => {
|
||||||
|
const events = createOperationalEvents([workOrder]);
|
||||||
|
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
|
||||||
|
expect(events[0].correlationId).toBe(events[1].correlationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters event lanes from the interactive legend", () => {
|
||||||
|
const events = createOperationalEvents([workOrder]);
|
||||||
|
const filtered = filterOperationalEvents(events, {
|
||||||
|
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||||
|
actual: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates attention events from failed exceptions", () => {
|
||||||
|
const [planEvent] = createOperationalEvents([workOrder]);
|
||||||
|
const attentionEvent = {
|
||||||
|
...planEvent,
|
||||||
|
id: "attention-1",
|
||||||
|
lane: "exception",
|
||||||
|
status: "delayed"
|
||||||
|
} satisfies OperationalEvent;
|
||||||
|
const exceptionEvent = {
|
||||||
|
...attentionEvent,
|
||||||
|
id: "exception-1",
|
||||||
|
status: "failed"
|
||||||
|
} satisfies OperationalEvent;
|
||||||
|
|
||||||
|
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
|
||||||
|
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
|
||||||
|
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
|
||||||
|
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||||
|
attention: false
|
||||||
|
})).toEqual([exceptionEvent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only promotes abnormal condition runs into exception events", () => {
|
||||||
|
const normal = {
|
||||||
|
id: "condition-normal",
|
||||||
|
kind: "condition",
|
||||||
|
taskId: "scada-diagnosis",
|
||||||
|
sessionId: "s-1",
|
||||||
|
scheduledAt: "2026-08-19T10:00:00.000Z",
|
||||||
|
title: "诊断",
|
||||||
|
summary: "正常",
|
||||||
|
status: "completed",
|
||||||
|
riskLevel: "normal",
|
||||||
|
updatedAt: 1
|
||||||
|
} satisfies ScheduledConditionItem;
|
||||||
|
const abnormal = {
|
||||||
|
...normal,
|
||||||
|
id: "condition-error",
|
||||||
|
status: "error",
|
||||||
|
riskLevel: "critical"
|
||||||
|
} satisfies ScheduledConditionItem;
|
||||||
|
|
||||||
|
expect(createOperationalEvents([normal])).toHaveLength(0);
|
||||||
|
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates nearby events into ten minute lane buckets", () => {
|
||||||
|
const events = createOperationalEvents([workOrder]);
|
||||||
|
const nearbyPlan = {
|
||||||
|
...events[0],
|
||||||
|
id: "plan-nearby",
|
||||||
|
cursorTime: "2026-08-19T10:05:00.000Z"
|
||||||
|
};
|
||||||
|
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
|
||||||
|
expect(clusters).toHaveLength(2);
|
||||||
|
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => {
|
||||||
|
expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true);
|
||||||
|
expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false);
|
||||||
|
expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => {
|
||||||
|
expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom: 24
|
||||||
|
});
|
||||||
|
expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom: 56
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import type { FeatureTarget } from "../map/workbench-map-controller";
|
||||||
|
import type {
|
||||||
|
ScheduledConditionItem,
|
||||||
|
ScheduledConditionRecord,
|
||||||
|
ScheduledWorkOrderItem
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
|
||||||
|
export type OperationalEventLane = "plan" | "actual" | "exception";
|
||||||
|
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
|
||||||
|
export type OperationalEventImportance = "normal" | "important" | "critical";
|
||||||
|
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
|
||||||
|
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
|
||||||
|
|
||||||
|
export type OperationalEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
lane: OperationalEventLane;
|
||||||
|
status: OperationalEventStatus;
|
||||||
|
importance: OperationalEventImportance;
|
||||||
|
plannedTime: string | null;
|
||||||
|
actualTime: string | null;
|
||||||
|
cursorTime: string;
|
||||||
|
correlationId: string | null;
|
||||||
|
scheduleId: string;
|
||||||
|
conditionId: string | null;
|
||||||
|
sourceLabel: string;
|
||||||
|
mapTargets: FeatureTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperationalEventCluster = {
|
||||||
|
id: string;
|
||||||
|
lane: OperationalEventLane;
|
||||||
|
bucketStart: number;
|
||||||
|
events: OperationalEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATIONAL_TIMELINE_LAYOUT = {
|
||||||
|
compact: { height: 88, maxWidth: 980 },
|
||||||
|
expanded: { height: 240, maxWidth: 1180 },
|
||||||
|
summary: { height: 52, maxWidth: 760 },
|
||||||
|
mobileSummaryHeight: 48,
|
||||||
|
mapScaleSafeWidth: 520,
|
||||||
|
floatingGap: 48,
|
||||||
|
edgeInset: 24,
|
||||||
|
scaleCollisionOffset: 56,
|
||||||
|
overlayReservedHeight: 0
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
|
||||||
|
plan: true,
|
||||||
|
actual: true,
|
||||||
|
attention: true,
|
||||||
|
exception: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineScaleLayout = {
|
||||||
|
scaleBottom: number;
|
||||||
|
timelineBottom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canCenterTimelineBesideMapScale(
|
||||||
|
businessWorkspaceWidth: number,
|
||||||
|
mode: OperationalTimelineMode,
|
||||||
|
visible: boolean
|
||||||
|
) {
|
||||||
|
if (!visible) return true;
|
||||||
|
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
|
||||||
|
return (
|
||||||
|
businessWorkspaceWidth >=
|
||||||
|
layout.maxWidth +
|
||||||
|
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineScaleLayout(
|
||||||
|
businessWorkspaceWidth: number,
|
||||||
|
mode: OperationalTimelineMode,
|
||||||
|
visible: boolean
|
||||||
|
): TimelineScaleLayout {
|
||||||
|
const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale(
|
||||||
|
businessWorkspaceWidth,
|
||||||
|
mode,
|
||||||
|
visible
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom:
|
||||||
|
visible && !scaleClearsCenteredTimeline
|
||||||
|
? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset
|
||||||
|
: OPERATIONAL_TIMELINE_LAYOUT.edgeInset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
||||||
|
return items
|
||||||
|
.flatMap((item) =>
|
||||||
|
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||||
|
)
|
||||||
|
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clusterOperationalEvents(
|
||||||
|
events: OperationalEvent[],
|
||||||
|
bucketMinutes = 10
|
||||||
|
): OperationalEventCluster[] {
|
||||||
|
const bucketMs = bucketMinutes * 60_000;
|
||||||
|
const clusters = new Map<string, OperationalEventCluster>();
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
const timestamp = Date.parse(event.cursorTime);
|
||||||
|
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
|
||||||
|
const key = `${event.lane}-${bucketStart}`;
|
||||||
|
const cluster = clusters.get(key) ?? {
|
||||||
|
id: key,
|
||||||
|
lane: event.lane,
|
||||||
|
bucketStart,
|
||||||
|
events: []
|
||||||
|
};
|
||||||
|
cluster.events.push(event);
|
||||||
|
clusters.set(key, cluster);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationalEventSummary(events: OperationalEvent[]) {
|
||||||
|
return {
|
||||||
|
planned: events.filter((event) => event.lane === "plan").length,
|
||||||
|
active: events.filter((event) => event.lane === "actual").length,
|
||||||
|
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
|
||||||
|
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
|
||||||
|
critical: events.filter((event) => event.importance === "critical").length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationalEventCategory(
|
||||||
|
event: OperationalEvent
|
||||||
|
): OperationalEventCategory {
|
||||||
|
if (event.lane === "plan" || event.lane === "actual") return event.lane;
|
||||||
|
return event.status === "failed" ? "exception" : "attention";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOperationalEvents(
|
||||||
|
events: OperationalEvent[],
|
||||||
|
visibility: OperationalEventVisibility
|
||||||
|
) {
|
||||||
|
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
|
||||||
|
if (item.status !== "warning" && item.status !== "error") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `exception-${item.id}`,
|
||||||
|
title: item.title,
|
||||||
|
description: item.report?.conclusion ?? item.summary,
|
||||||
|
lane: "exception",
|
||||||
|
status: item.status === "error" ? "failed" : "delayed",
|
||||||
|
importance: item.status === "error" ? "critical" : "important",
|
||||||
|
plannedTime: item.scheduledAt,
|
||||||
|
actualTime: item.scheduledAt,
|
||||||
|
cursorTime: item.scheduledAt,
|
||||||
|
correlationId: item.sessionId,
|
||||||
|
scheduleId: item.taskId,
|
||||||
|
conditionId: item.id,
|
||||||
|
sourceLabel: "工况诊断",
|
||||||
|
mapTargets: getConditionMapTargets(item)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
|
||||||
|
const planEvent: OperationalEvent = {
|
||||||
|
id: `plan-${item.id}`,
|
||||||
|
title: item.title,
|
||||||
|
description: `${item.location} · ${item.assignee}`,
|
||||||
|
lane: "plan",
|
||||||
|
status: "planned",
|
||||||
|
importance: item.priority === "urgent" ? "important" : "normal",
|
||||||
|
plannedTime: item.scheduledAt,
|
||||||
|
actualTime: null,
|
||||||
|
cursorTime: item.scheduledAt,
|
||||||
|
correlationId: item.code,
|
||||||
|
scheduleId: item.id,
|
||||||
|
conditionId: item.id,
|
||||||
|
sourceLabel: item.source,
|
||||||
|
mapTargets: getWorkOrderMapTargets(item)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.status === "pending") {
|
||||||
|
return [planEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.parse(item.scheduledAt);
|
||||||
|
const actualTime = new Date(
|
||||||
|
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
|
||||||
|
).toISOString();
|
||||||
|
const actualEvent: OperationalEvent = {
|
||||||
|
...planEvent,
|
||||||
|
id: `actual-${item.id}`,
|
||||||
|
lane: "actual",
|
||||||
|
status: "executed",
|
||||||
|
actualTime,
|
||||||
|
cursorTime: actualTime,
|
||||||
|
description:
|
||||||
|
item.status === "running"
|
||||||
|
? `${item.assignee}正在执行,位置:${item.location}`
|
||||||
|
: `${item.assignee}已完成现场执行,等待调度复令`
|
||||||
|
};
|
||||||
|
|
||||||
|
return [planEvent, actualEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
|
||||||
|
if (item.taskId === "scada-diagnosis") {
|
||||||
|
return [{ sourceId: "scada", featureId: "SCADA-07" }];
|
||||||
|
}
|
||||||
|
if (item.taskId === "pump-energy") {
|
||||||
|
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
|
||||||
|
if (item.location.includes("阀")) {
|
||||||
|
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
|
||||||
|
}
|
||||||
|
if (item.location.includes("流量计")) {
|
||||||
|
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssetId(location: string) {
|
||||||
|
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Check,
|
||||||
|
Clock3,
|
||||||
|
OctagonAlert,
|
||||||
|
PanelBottomClose,
|
||||||
|
PanelBottomOpen,
|
||||||
|
RotateCcw,
|
||||||
|
TriangleAlert
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
import {
|
||||||
|
clusterOperationalEvents,
|
||||||
|
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||||
|
filterOperationalEvents,
|
||||||
|
getOperationalEventSummary,
|
||||||
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
|
type OperationalEvent,
|
||||||
|
type OperationalEventCategory,
|
||||||
|
type OperationalEventCluster,
|
||||||
|
type OperationalEventLane,
|
||||||
|
type OperationalEventVisibility,
|
||||||
|
type OperationalTimelineMode
|
||||||
|
} from "./operational-timeline-model";
|
||||||
|
|
||||||
|
type OperationalTimelineProps = {
|
||||||
|
date: string;
|
||||||
|
cursorTime: string;
|
||||||
|
events: OperationalEvent[];
|
||||||
|
mode: OperationalTimelineMode;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
mobile?: boolean;
|
||||||
|
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||||
|
onSelectEvent: (event: OperationalEvent) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onReturnNow: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
||||||
|
{ id: "plan", label: "计划" },
|
||||||
|
{ id: "actual", label: "实际" },
|
||||||
|
{ id: "exception", label: "异常" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
|
||||||
|
{ id: "plan", label: "计划" },
|
||||||
|
{ id: "actual", label: "实际" },
|
||||||
|
{ id: "attention", label: "关注" },
|
||||||
|
{ id: "exception", label: "异常" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OperationalTimeline({
|
||||||
|
date,
|
||||||
|
cursorTime,
|
||||||
|
events,
|
||||||
|
mode,
|
||||||
|
selectedEventId,
|
||||||
|
mobile = false,
|
||||||
|
onModeChange,
|
||||||
|
onSelectEvent,
|
||||||
|
onClearSelection,
|
||||||
|
onReturnNow
|
||||||
|
}: OperationalTimelineProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
|
||||||
|
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
|
||||||
|
}));
|
||||||
|
const summary = getOperationalEventSummary(events);
|
||||||
|
const visibleEvents = filterOperationalEvents(events, eventVisibility);
|
||||||
|
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
|
||||||
|
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
||||||
|
const cursorPosition = getDayPosition(cursorTime);
|
||||||
|
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
|
||||||
|
const sizeTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
|
||||||
|
if (mode === "summary" && !mobile) {
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
aria-label="运行时间轴摘要"
|
||||||
|
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
||||||
|
initial={false}
|
||||||
|
animate={{ height }}
|
||||||
|
transition={sizeTransition}
|
||||||
|
>
|
||||||
|
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||||
|
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
||||||
|
<span className="h-4 w-px bg-slate-300" />
|
||||||
|
<TimelineSummary summary={summary} />
|
||||||
|
{selectedEvent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
已选 {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
||||||
|
)}
|
||||||
|
<TimelineLegend
|
||||||
|
visibility={eventVisibility}
|
||||||
|
onVisibilityChange={setEventVisibility}
|
||||||
|
/>
|
||||||
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={180}>
|
||||||
|
<motion.section
|
||||||
|
aria-label="运行时间轴"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||||
|
mobile
|
||||||
|
? "surface-reading h-full rounded-t-xl border-t"
|
||||||
|
: "acrylic-panel rounded-2xl border"
|
||||||
|
)}
|
||||||
|
initial={false}
|
||||||
|
animate={{ height }}
|
||||||
|
transition={sizeTransition}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||||
|
mode === "compact" && !mobile ? "h-10" : "h-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
|
||||||
|
((mode === "compact" && !mobile) || mobile) && "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
运行时间轴
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
||||||
|
{!mobile ? <TimelineSummary summary={summary} /> : null}
|
||||||
|
{selectedEvent && !mobile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
title="清除当前事件选择"
|
||||||
|
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<TimelineLegend
|
||||||
|
visibility={eventVisibility}
|
||||||
|
onVisibilityChange={setEventVisibility}
|
||||||
|
/>
|
||||||
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
{mode === "expanded" || mobile ? (
|
||||||
|
<motion.div
|
||||||
|
key="expanded-timeline"
|
||||||
|
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
||||||
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
|
||||||
|
<span />
|
||||||
|
{LANES.map((lane) => (
|
||||||
|
<span key={lane.id} className="flex items-center justify-center font-semibold">
|
||||||
|
{lane.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
||||||
|
<TimeTicks />
|
||||||
|
{LANES.map((lane) => (
|
||||||
|
<TimelineLane
|
||||||
|
key={lane.id}
|
||||||
|
lane={lane.id}
|
||||||
|
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
onSelectEvent={onSelectEvent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
|
||||||
|
style={{ left: `${cursorPosition}%` }}
|
||||||
|
>
|
||||||
|
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="compact-timeline"
|
||||||
|
className="relative min-h-0 flex-1 px-3"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
||||||
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||||
|
>
|
||||||
|
<CompactTimeTicks />
|
||||||
|
<div className="absolute bottom-2 left-3 right-3 top-4">
|
||||||
|
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
||||||
|
{clusters.map((cluster, index) => (
|
||||||
|
<TimelineMarker
|
||||||
|
key={cluster.id}
|
||||||
|
cluster={cluster}
|
||||||
|
compact
|
||||||
|
markerIndex={index}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
onSelectEvent={onSelectEvent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
|
||||||
|
style={{ left: `${cursorPosition}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.section>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineLane({
|
||||||
|
lane,
|
||||||
|
clusters,
|
||||||
|
selectedEventId,
|
||||||
|
onSelectEvent
|
||||||
|
}: {
|
||||||
|
lane: OperationalEventLane;
|
||||||
|
clusters: OperationalEventCluster[];
|
||||||
|
selectedEventId: string | null;
|
||||||
|
onSelectEvent: (event: OperationalEvent) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative border-t border-slate-200" data-lane={lane}>
|
||||||
|
{clusters.map((cluster, index) => (
|
||||||
|
<TimelineMarker
|
||||||
|
key={cluster.id}
|
||||||
|
cluster={cluster}
|
||||||
|
markerIndex={index}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
onSelectEvent={onSelectEvent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineMarker({
|
||||||
|
cluster,
|
||||||
|
compact = false,
|
||||||
|
markerIndex,
|
||||||
|
selectedEventId,
|
||||||
|
onSelectEvent
|
||||||
|
}: {
|
||||||
|
cluster: OperationalEventCluster;
|
||||||
|
compact?: boolean;
|
||||||
|
markerIndex: number;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
onSelectEvent: (event: OperationalEvent) => void;
|
||||||
|
}) {
|
||||||
|
const event = pickClusterEvent(cluster.events);
|
||||||
|
const selected = cluster.events.some((item) => item.id === selectedEventId);
|
||||||
|
const position = getDayPosition(event.cursorTime);
|
||||||
|
const exception = event.lane === "exception";
|
||||||
|
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
|
||||||
|
onClick={() => onSelectEvent(event)}
|
||||||
|
className={cn(
|
||||||
|
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
|
||||||
|
compact ? "h-2 w-2" : "h-5 w-5",
|
||||||
|
event.lane === "plan" &&
|
||||||
|
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
|
||||||
|
event.lane === "actual" &&
|
||||||
|
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
|
||||||
|
exception &&
|
||||||
|
event.status !== "failed" &&
|
||||||
|
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
|
||||||
|
event.status === "failed" &&
|
||||||
|
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
|
||||||
|
event.importance === "critical" && !compact && "h-6 w-6",
|
||||||
|
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
|
||||||
|
)}
|
||||||
|
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
|
||||||
|
>
|
||||||
|
{!compact ? <OperationalEventGlyph event={event} /> : null}
|
||||||
|
{cluster.events.length > 1 && !compact ? (
|
||||||
|
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
|
||||||
|
{cluster.events.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
sideOffset={10}
|
||||||
|
className={cn(
|
||||||
|
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
|
||||||
|
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
|
||||||
|
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
|
event.lane === "exception" &&
|
||||||
|
event.status !== "failed" &&
|
||||||
|
"border-amber-200 bg-amber-50 text-amber-800",
|
||||||
|
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<OperationalEventGlyph event={event} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-bold tracking-[0.08em]",
|
||||||
|
event.lane === "plan" && "text-blue-700",
|
||||||
|
event.lane === "actual" && "text-emerald-700",
|
||||||
|
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
|
||||||
|
event.status === "failed" && "text-rose-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{operationalEventNatureLabel(event)}
|
||||||
|
</span>
|
||||||
|
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
|
||||||
|
{formatClock(event.cursorTime)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
|
||||||
|
{event.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
{cluster.events.length > 1 ? (
|
||||||
|
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
||||||
|
if (event.status === "failed") {
|
||||||
|
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
if (event.lane === "exception") {
|
||||||
|
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
if (event.lane === "actual") {
|
||||||
|
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationalEventNatureLabel(event: OperationalEvent) {
|
||||||
|
if (event.status === "failed") return "异常";
|
||||||
|
if (event.lane === "exception") return "关注";
|
||||||
|
if (event.lane === "actual") return "实际执行";
|
||||||
|
return "计划任务";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
|
||||||
|
<span>
|
||||||
|
计划 <strong className="text-blue-700">{summary.planned}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
执行 <strong className="text-emerald-700">{summary.active}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
关注 <strong className="text-amber-700">{summary.attention}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
异常 <strong className="text-rose-700">{summary.exceptions}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineActions({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
onReturnNow
|
||||||
|
}: {
|
||||||
|
mode: OperationalTimelineMode;
|
||||||
|
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||||
|
onReturnNow: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="返回现在"
|
||||||
|
aria-label="返回现在"
|
||||||
|
onClick={onReturnNow}
|
||||||
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">现在</span>
|
||||||
|
</button>
|
||||||
|
{onModeChange ? (
|
||||||
|
<>
|
||||||
|
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||||
|
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||||
|
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
||||||
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{mode === "expanded" ? (
|
||||||
|
<PanelBottomClose size={14} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<PanelBottomOpen size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{mode === "expanded" ? "收起" : "展开"}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineLegend({
|
||||||
|
visibility,
|
||||||
|
onVisibilityChange
|
||||||
|
}: {
|
||||||
|
visibility: OperationalEventVisibility;
|
||||||
|
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label="时间轴图例"
|
||||||
|
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
|
||||||
|
>
|
||||||
|
{LEGEND_ITEMS.map((item) => {
|
||||||
|
const visible = visibility[item.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||||
|
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||||
|
aria-pressed={visible}
|
||||||
|
onClick={() =>
|
||||||
|
onVisibilityChange({
|
||||||
|
...visibility,
|
||||||
|
[item.id]: !visible
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
|
||||||
|
visible ? "opacity-100" : "grayscale opacity-25"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LegendSwatch category={item.id} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block h-3.5 w-3.5 shrink-0 border",
|
||||||
|
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
|
||||||
|
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
|
||||||
|
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
|
||||||
|
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeTicks() {
|
||||||
|
return (
|
||||||
|
<div className="relative text-[9px] text-slate-600">
|
||||||
|
{[0, 6, 12, 18, 24].map((hour) => (
|
||||||
|
<span
|
||||||
|
key={hour}
|
||||||
|
className={cn(
|
||||||
|
"absolute",
|
||||||
|
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||||
|
)}
|
||||||
|
style={{ left: `${(hour / 24) * 100}%` }}
|
||||||
|
>
|
||||||
|
{String(hour).padStart(2, "0")}:00
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactTimeTicks() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
|
||||||
|
{[0, 6, 12, 18, 24].map((hour) => (
|
||||||
|
<span
|
||||||
|
key={hour}
|
||||||
|
className={cn(
|
||||||
|
"absolute",
|
||||||
|
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||||
|
)}
|
||||||
|
style={{ left: `${(hour / 24) * 100}%` }}
|
||||||
|
>
|
||||||
|
{String(hour).padStart(2, "0")}:00
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickClusterEvent(events: OperationalEvent[]) {
|
||||||
|
return [...events].sort(
|
||||||
|
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function importanceRank(value: OperationalEvent["importance"]) {
|
||||||
|
return value === "critical" ? 2 : value === "important" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayPosition(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
|
||||||
|
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const [year, month, day] = value.split("-");
|
||||||
|
return `${year}.${month}.${day}`;
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
|
|
||||||
describe("feature panel properties", () => {
|
describe("feature panel properties", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["pipes", ["id", "diameter", "length", "material"]],
|
["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]],
|
||||||
["junctions", ["id", "elevation", "demand", "pressure"]]
|
["junctions", ["id", "elevation", "base_demand"]]
|
||||||
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
||||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||||
|
|
||||||
@@ -20,16 +20,22 @@ describe("feature panel properties", () => {
|
|||||||
it("formats pipe diameter from GeoServer DN millimeter values", () => {
|
it("formats pipe diameter from GeoServer DN millimeter values", () => {
|
||||||
const entries = getFeaturePanelProperties("pipes", {
|
const entries = getFeaturePanelProperties("pipes", {
|
||||||
id: "P-1",
|
id: "P-1",
|
||||||
|
start_node_id: "J-1",
|
||||||
|
end_node_id: "J-2",
|
||||||
diameter: 600,
|
diameter: 600,
|
||||||
length: 42.5,
|
length: 42.5,
|
||||||
material: "DI"
|
roughness: 120,
|
||||||
|
status: "OPEN"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "P-1"],
|
["编号", "P-1"],
|
||||||
|
["起点节点", "J-1"],
|
||||||
|
["终点节点", "J-2"],
|
||||||
["管径", "600 mm"],
|
["管径", "600 mm"],
|
||||||
["长度", "42.50 m"],
|
["长度", "42.50 m"],
|
||||||
["材质", "球墨铸铁"]
|
["粗糙系数", "120"],
|
||||||
|
["状态", "开启"]
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,14 +43,14 @@ describe("feature panel properties", () => {
|
|||||||
const metrics = getFeatureInsightMetrics("pipes", {
|
const metrics = getFeatureInsightMetrics("pipes", {
|
||||||
diameter: 600,
|
diameter: 600,
|
||||||
length: 42.5,
|
length: 42.5,
|
||||||
material: "DI",
|
roughness: 120,
|
||||||
status: "OPEN"
|
status: "OPEN"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(metrics).toEqual([
|
expect(metrics).toEqual([
|
||||||
{ label: "管径", value: "600 mm" },
|
{ label: "管径", value: "600 mm" },
|
||||||
{ label: "长度", value: "42.50 m" },
|
{ label: "长度", value: "42.50 m" },
|
||||||
{ label: "材质", value: "球墨铸铁" },
|
{ label: "粗糙系数", value: "120" },
|
||||||
{ label: "状态", value: "开启" }
|
{ label: "状态", value: "开启" }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -52,56 +58,62 @@ describe("feature panel properties", () => {
|
|||||||
it("localizes common enumerated property values in the panel", () => {
|
it("localizes common enumerated property values in the panel", () => {
|
||||||
expect(getFeaturePanelProperties("valves", {
|
expect(getFeaturePanelProperties("valves", {
|
||||||
id: "V-1",
|
id: "V-1",
|
||||||
node1: "J-1",
|
start_node_id: "J-1",
|
||||||
node2: "J-2",
|
end_node_id: "J-2",
|
||||||
diameter: 300,
|
diameter: 300,
|
||||||
v_type: "PRV",
|
valve_type: "PRV",
|
||||||
setting: "OPEN"
|
setting: "OPEN",
|
||||||
|
minor_loss: 0.2
|
||||||
}).map(({ label, value }) => [label, value])).toEqual([
|
}).map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "V-1"],
|
["编号", "V-1"],
|
||||||
["起点节点", "J-1"],
|
["起点节点", "J-1"],
|
||||||
["终点节点", "J-2"],
|
["终点节点", "J-2"],
|
||||||
["管径", "300 mm"],
|
["管径", "300 mm"],
|
||||||
["阀门类型", "减压阀"],
|
["阀门类型", "减压阀"],
|
||||||
["阀门设定", "开启"]
|
["阀门设定", "开启"],
|
||||||
|
["局部损失系数", "0.20"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFeaturePanelProperties("scada", {
|
expect(getFeaturePanelProperties("scada", {
|
||||||
id: "S-1",
|
device_id: "S-1",
|
||||||
type: "pressure",
|
device_type: "pressure",
|
||||||
associated_element_id: "J-1",
|
node_id: "J-1",
|
||||||
|
link_id: null,
|
||||||
|
api_query_id: "query-1",
|
||||||
transmission_mode: "non_realtime",
|
transmission_mode: "non_realtime",
|
||||||
transmission_frequency: "15min",
|
transmission_frequency: "15min",
|
||||||
reliability: "high"
|
reliability: "high"
|
||||||
}).map(({ label, value }) => [label, value])).toEqual([
|
}).map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "S-1"],
|
["设备 ID", "S-1"],
|
||||||
["类型", "压力监测"],
|
["设备类型", "压力监测"],
|
||||||
["关联资产", "J-1"],
|
["关联节点", "J-1"],
|
||||||
|
["关联连线", "暂无"],
|
||||||
|
["采集接口", "query-1"],
|
||||||
["传输方式", "非实时"],
|
["传输方式", "非实时"],
|
||||||
["传输频率", "15min"],
|
["传输频率", "15min"],
|
||||||
["可靠性", "高"]
|
["可靠性", "高"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFeaturePanelProperties("scada", {
|
expect(getFeaturePanelProperties("scada", {
|
||||||
id: "S-2",
|
device_id: "S-2",
|
||||||
type: "pipe_flow"
|
device_type: "pipe_flow"
|
||||||
}).find((entry) => entry.key === "type")?.value).toBe("流量监测");
|
}).find((entry) => entry.key === "device_type")?.value).toBe("流量监测");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the map feature id when the properties omit it", () => {
|
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.id).toBe("map-feature-id");
|
||||||
expect(model.badge).toBeUndefined();
|
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([
|
it.each([
|
||||||
["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]],
|
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
|
||||||
["reservoirs", ["id", "head", "pattern"]],
|
["reservoirs", ["id", "head", "pattern_id"]],
|
||||||
["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||||
["pumps", ["id", "node1", "node2", "status"]],
|
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
|
||||||
["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]]
|
["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) => {
|
] as const)("localizes configured %s panel field labels", (layer, keys) => {
|
||||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ describe("feature panel properties", () => {
|
|||||||
label: getFeaturePropertyLabel(entry.key)
|
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_]*$/));
|
.not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
fid: "GeoServer 要素 ID",
|
fid: "GeoServer 要素 ID",
|
||||||
id: "编号",
|
id: "编号",
|
||||||
scada_id: "编号 UUID",
|
scada_id: "编号 UUID",
|
||||||
sensor_id: "传感器 ID",
|
|
||||||
sensor_name: "传感器名称",
|
|
||||||
swmm_node: "SWMM 节点",
|
swmm_node: "SWMM 节点",
|
||||||
topology_order: "拓扑序",
|
topology_order: "拓扑序",
|
||||||
distance_to_wwtp_m: "距处理厂距离",
|
distance_to_wwtp_m: "距处理厂距离",
|
||||||
@@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
name: "名称",
|
name: "名称",
|
||||||
code: "编码",
|
code: "编码",
|
||||||
type: "类型",
|
type: "类型",
|
||||||
|
device_type: "设备类型",
|
||||||
node1: "起点节点",
|
node1: "起点节点",
|
||||||
node2: "终点节点",
|
node2: "终点节点",
|
||||||
|
start_node_id: "起点节点",
|
||||||
|
end_node_id: "终点节点",
|
||||||
from_node: "起点节点",
|
from_node: "起点节点",
|
||||||
to_node: "终点节点",
|
to_node: "终点节点",
|
||||||
diameter: "管径",
|
diameter: "管径",
|
||||||
@@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
max_depth: "最大深度",
|
max_depth: "最大深度",
|
||||||
invert_elevation: "井底高程",
|
invert_elevation: "井底高程",
|
||||||
v_type: "阀门类型",
|
v_type: "阀门类型",
|
||||||
|
valve_type: "阀门类型",
|
||||||
setting: "阀门设定",
|
setting: "阀门设定",
|
||||||
minor_loss: "局部损失系数",
|
minor_loss: "局部损失系数",
|
||||||
orifice_type: "孔口类型",
|
orifice_type: "孔口类型",
|
||||||
@@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
status: "状态",
|
status: "状态",
|
||||||
head: "水头",
|
head: "水头",
|
||||||
pattern: "模式",
|
pattern: "模式",
|
||||||
|
pattern_id: "模式",
|
||||||
|
head_curve_id: "扬程曲线",
|
||||||
|
volume_curve_id: "容积曲线",
|
||||||
init_level: "初始水位",
|
init_level: "初始水位",
|
||||||
min_level: "最低水位",
|
min_level: "最低水位",
|
||||||
max_level: "最高水位",
|
max_level: "最高水位",
|
||||||
|
initial_level: "初始水位",
|
||||||
|
minimum_level: "最低水位",
|
||||||
|
maximum_level: "最高水位",
|
||||||
|
minimum_volume: "最小容积",
|
||||||
|
overflow: "允许溢流",
|
||||||
|
power: "功率",
|
||||||
|
speed: "转速倍率",
|
||||||
material: "材质",
|
material: "材质",
|
||||||
elevation: "高程",
|
elevation: "高程",
|
||||||
demand: "需水量",
|
demand: "需水量",
|
||||||
|
base_demand: "基础需水量",
|
||||||
|
demands: "需水配置",
|
||||||
pressure: "压力",
|
pressure: "压力",
|
||||||
flow: "流量",
|
flow: "流量",
|
||||||
velocity: "流速",
|
velocity: "流速",
|
||||||
@@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
point_external_id: "测点编号",
|
point_external_id: "测点编号",
|
||||||
point_name: "测点名称",
|
point_name: "测点名称",
|
||||||
associated_element_id: "关联资产",
|
associated_element_id: "关联资产",
|
||||||
|
node_id: "关联节点",
|
||||||
|
link_id: "关联连线",
|
||||||
|
api_query_id: "采集接口",
|
||||||
transmission_mode: "传输方式",
|
transmission_mode: "传输方式",
|
||||||
transmission_frequency: "传输频率",
|
transmission_frequency: "传输频率",
|
||||||
reliability: "可靠性",
|
reliability: "可靠性",
|
||||||
@@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
fid: 9,
|
fid: 9,
|
||||||
id: 10,
|
id: 10,
|
||||||
scada_id: 10,
|
scada_id: 10,
|
||||||
sensor_id: 10,
|
|
||||||
sensor_name: 12,
|
|
||||||
swmm_node: 13,
|
swmm_node: 13,
|
||||||
topology_order: 14,
|
topology_order: 14,
|
||||||
distance_to_wwtp_m: 15,
|
distance_to_wwtp_m: 15,
|
||||||
@@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
material: 23,
|
material: 23,
|
||||||
node1: 24,
|
node1: 24,
|
||||||
from_node: 24,
|
from_node: 24,
|
||||||
|
start_node_id: 24,
|
||||||
node2: 25,
|
node2: 25,
|
||||||
to_node: 25,
|
to_node: 25,
|
||||||
|
end_node_id: 25,
|
||||||
elevation: 30,
|
elevation: 30,
|
||||||
demand: 31,
|
demand: 31,
|
||||||
|
base_demand: 31,
|
||||||
pressure: 32,
|
pressure: 32,
|
||||||
flow: 33,
|
flow: 33,
|
||||||
velocity: 34,
|
velocity: 34,
|
||||||
@@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||||
pipes: ["id", "diameter", "length", "material"],
|
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
|
||||||
junctions: ["id", "elevation", "demand", "pressure"],
|
junctions: ["id", "elevation", "base_demand"],
|
||||||
valves: ["id", "node1", "node2", "diameter", "v_type", "setting"],
|
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
|
||||||
reservoirs: ["id", "head", "pattern"],
|
reservoirs: ["id", "head", "pattern_id"],
|
||||||
scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"],
|
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
|
||||||
pumps: ["id", "node1", "node2", "status"],
|
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
|
||||||
tanks: ["id", "elevation", "init_level", "min_level", "max_level"]
|
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||||
@@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
|
|||||||
pipes: [
|
pipes: [
|
||||||
{ label: "管径", key: "diameter" },
|
{ label: "管径", key: "diameter" },
|
||||||
{ label: "长度", key: "length" },
|
{ label: "长度", key: "length" },
|
||||||
{ label: "材质", key: "material" },
|
{ label: "粗糙系数", key: "roughness" },
|
||||||
{ label: "状态", key: "status" }
|
{ label: "状态", key: "status" }
|
||||||
],
|
],
|
||||||
junctions: [
|
junctions: [
|
||||||
{ label: "需水量", key: "demand" },
|
{ label: "基础需水量", key: "base_demand" },
|
||||||
{ label: "高程", key: "elevation" },
|
{ label: "高程", key: "elevation" },
|
||||||
{ label: "压力", key: "pressure" },
|
{ label: "X 坐标", key: "x" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "Y 坐标", key: "y" }
|
||||||
],
|
],
|
||||||
valves: [
|
valves: [
|
||||||
{ label: "管径", key: "diameter" },
|
{ label: "管径", key: "diameter" },
|
||||||
{ label: "类型", key: "v_type" },
|
{ label: "类型", key: "valve_type" },
|
||||||
{ label: "设定", key: "setting" },
|
{ label: "设定", key: "setting" },
|
||||||
{ label: "局部损失", key: "minor_loss" }
|
{ label: "局部损失", key: "minor_loss" }
|
||||||
],
|
],
|
||||||
reservoirs: [
|
reservoirs: [
|
||||||
{ label: "水头", key: "head" },
|
{ label: "水头", key: "head" },
|
||||||
{ label: "模式", key: "pattern" },
|
{ label: "模式", key: "pattern_id" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "X 坐标", key: "x" },
|
||||||
|
{ label: "Y 坐标", key: "y" }
|
||||||
],
|
],
|
||||||
scada: [
|
scada: [
|
||||||
{ label: "类型", key: "type" },
|
{ label: "类型", key: "device_type" },
|
||||||
{ label: "关联资产", key: "associated_element_id" },
|
{ label: "关联节点", key: "node_id" },
|
||||||
{ label: "传输方式", key: "transmission_mode" },
|
{ label: "关联连线", key: "link_id" },
|
||||||
{ label: "可靠性", key: "reliability" }
|
{ label: "传输方式", key: "transmission_mode" }
|
||||||
],
|
],
|
||||||
pumps: [
|
pumps: [
|
||||||
{ label: "起点", key: "node1" },
|
{ label: "功率", key: "power" },
|
||||||
{ label: "终点", key: "node2" },
|
{ label: "扬程曲线", key: "head_curve_id" },
|
||||||
{ label: "状态", key: "status" }
|
{ label: "转速倍率", key: "speed" },
|
||||||
|
{ label: "模式", key: "pattern_id" }
|
||||||
],
|
],
|
||||||
tanks: [
|
tanks: [
|
||||||
{ label: "高程", key: "elevation" },
|
{ label: "高程", key: "elevation" },
|
||||||
{ label: "初始水位", key: "init_level" },
|
{ label: "初始水位", key: "initial_level" },
|
||||||
{ label: "最高水位", key: "max_level" },
|
{ label: "最低水位", key: "minimum_level" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "最高水位", key: "maximum_level" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,7 +373,7 @@ export function getFeaturePanelProperties(
|
|||||||
): LocalizedFeatureProperty[] {
|
): LocalizedFeatureProperty[] {
|
||||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
||||||
const propertyValue = getPropertyValue(properties, key);
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -391,7 +411,7 @@ export function getFeaturePanelModel(
|
|||||||
featureId?: string
|
featureId?: string
|
||||||
): FeaturePanelModel {
|
): FeaturePanelModel {
|
||||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
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 id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
||||||
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
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;
|
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);
|
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
: formatValue(value);
|
: 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 !== "") {
|
&& value !== null && value !== undefined && value !== "") {
|
||||||
return `${formatValue(value)} m`;
|
return `${formatValue(value)} m`;
|
||||||
}
|
}
|
||||||
@@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return STATUS_LABELS[normalizedValue];
|
return STATUS_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "v_type") {
|
if (normalizedKey === "v_type" || normalizedKey === "valve_type") {
|
||||||
return VALVE_TYPE_LABELS[normalizedValue];
|
return VALVE_TYPE_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return MATERIAL_LABELS[normalizedValue];
|
return MATERIAL_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "type") {
|
if (normalizedKey === "type" || normalizedKey === "device_type") {
|
||||||
return ASSET_TYPE_LABELS[normalizedValue];
|
return ASSET_TYPE_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return RELIABILITY_LABELS[normalizedValue];
|
return RELIABILITY_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "pattern") {
|
if (normalizedKey === "pattern" || normalizedKey === "pattern_id") {
|
||||||
return PATTERN_LABELS[normalizedValue];
|
return PATTERN_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import type { AnalysisArtifact } from "./workspace-model";
|
||||||
|
|
||||||
|
type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
|
||||||
|
|
||||||
|
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
type: "diagnosis",
|
||||||
|
lifecycle: "reviewed",
|
||||||
|
revision: 3,
|
||||||
|
title: "北辰分区压力异常诊断",
|
||||||
|
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||||
|
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
|
||||||
|
scope: "北辰供水分区 · 最近 6 小时",
|
||||||
|
confidence: "92.6%",
|
||||||
|
generatedAt,
|
||||||
|
analyticalTimeRange: {
|
||||||
|
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
|
||||||
|
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||||
|
},
|
||||||
|
preferredView: "map_split",
|
||||||
|
mapRelation: "required",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "pressure-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "诊断摘要",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
|
||||||
|
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
|
||||||
|
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
|
||||||
|
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-trend",
|
||||||
|
kind: "chart",
|
||||||
|
title: "关键节点压力曲线",
|
||||||
|
description: "BC-P-014 在 09:40 后持续低于调度下限",
|
||||||
|
span: 7,
|
||||||
|
chartType: "line",
|
||||||
|
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
|
||||||
|
unit: "MPa",
|
||||||
|
series: [
|
||||||
|
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
|
||||||
|
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
|
||||||
|
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-ranking",
|
||||||
|
kind: "chart",
|
||||||
|
title: "异常偏差排名",
|
||||||
|
description: "按相对调度下限的压力偏差排序",
|
||||||
|
span: 5,
|
||||||
|
chartType: "bar",
|
||||||
|
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
|
||||||
|
unit: "%",
|
||||||
|
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-evidence",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "证据链",
|
||||||
|
description: "对诊断结论贡献最高的观测证据",
|
||||||
|
span: 12,
|
||||||
|
columns: [
|
||||||
|
{ key: "time", label: "时间" },
|
||||||
|
{ key: "source", label: "数据源" },
|
||||||
|
{ key: "finding", label: "观测" },
|
||||||
|
{ key: "impact", label: "影响" },
|
||||||
|
{ key: "confidence", label: "置信度", numeric: true }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
|
||||||
|
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
|
||||||
|
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
|
||||||
|
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
|
||||||
|
actions: [
|
||||||
|
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
|
||||||
|
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
type: "metric_analysis",
|
||||||
|
lifecycle: "draft",
|
||||||
|
revision: 2,
|
||||||
|
title: "供水服务分区平衡分析",
|
||||||
|
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||||
|
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
|
||||||
|
scope: "六个服务分区 · 今日峰值预测",
|
||||||
|
confidence: "88.4%",
|
||||||
|
generatedAt,
|
||||||
|
analyticalTimeRange: {
|
||||||
|
start: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(0, 0, 0, 0)).toISOString(),
|
||||||
|
end: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(23, 59, 59, 999)).toISOString()
|
||||||
|
},
|
||||||
|
preferredView: "focus",
|
||||||
|
mapRelation: "none",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "zone-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "分区态势",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
|
||||||
|
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
|
||||||
|
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
|
||||||
|
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-balance",
|
||||||
|
kind: "chart",
|
||||||
|
title: "分区供需对比",
|
||||||
|
description: "北辰与东丽当前需求已接近可用供水能力",
|
||||||
|
span: 7,
|
||||||
|
chartType: "bar",
|
||||||
|
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
|
||||||
|
unit: "万m³/d",
|
||||||
|
series: [
|
||||||
|
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
|
||||||
|
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-risk",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "调配优先级",
|
||||||
|
description: "按负荷率、连续供水和通道可用性综合排序",
|
||||||
|
span: 5,
|
||||||
|
columns: [
|
||||||
|
{ key: "zone", label: "分区" },
|
||||||
|
{ key: "load", label: "负荷率", numeric: true },
|
||||||
|
{ key: "risk", label: "风险" },
|
||||||
|
{ key: "action", label: "建议" }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
|
||||||
|
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
|
||||||
|
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
|
||||||
|
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone-note",
|
||||||
|
kind: "narrative",
|
||||||
|
title: "Agent 结论",
|
||||||
|
span: 12,
|
||||||
|
paragraphs: [
|
||||||
|
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
|
||||||
|
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
|
||||||
|
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
|
||||||
|
actions: [
|
||||||
|
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
|
||||||
|
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||||
|
id,
|
||||||
|
type: "simulation",
|
||||||
|
lifecycle: "published",
|
||||||
|
revision: 5,
|
||||||
|
title: "低压事件调度处置流程",
|
||||||
|
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||||
|
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
|
||||||
|
scope: "北辰低压事件 · 30 分钟模拟",
|
||||||
|
confidence: "90.8%",
|
||||||
|
generatedAt,
|
||||||
|
analyticalTimeRange: {
|
||||||
|
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
|
||||||
|
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||||
|
},
|
||||||
|
preferredView: "map_split",
|
||||||
|
mapRelation: "required",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "flow-metrics",
|
||||||
|
kind: "metric-grid",
|
||||||
|
title: "执行状态",
|
||||||
|
span: 12,
|
||||||
|
metrics: [
|
||||||
|
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
|
||||||
|
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
|
||||||
|
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
|
||||||
|
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-process",
|
||||||
|
kind: "process-flow",
|
||||||
|
title: "处置路径",
|
||||||
|
description: "所有调度动作均需在执行前完成权限和现场状态确认",
|
||||||
|
span: 7,
|
||||||
|
nodes: [
|
||||||
|
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
|
||||||
|
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
|
||||||
|
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
|
||||||
|
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
|
||||||
|
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-checklist",
|
||||||
|
kind: "data-table",
|
||||||
|
title: "执行清单",
|
||||||
|
description: "当前阶段需要完成的人员与系统确认",
|
||||||
|
span: 5,
|
||||||
|
columns: [
|
||||||
|
{ key: "owner", label: "责任方" },
|
||||||
|
{ key: "item", label: "确认项" },
|
||||||
|
{ key: "state", label: "状态" }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
|
||||||
|
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
|
||||||
|
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
|
||||||
|
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-progress",
|
||||||
|
kind: "chart",
|
||||||
|
title: "处置前后压力预测",
|
||||||
|
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
|
||||||
|
span: 12,
|
||||||
|
chartType: "line",
|
||||||
|
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
|
||||||
|
unit: "MPa",
|
||||||
|
series: [
|
||||||
|
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
|
||||||
|
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
|
||||||
|
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
|
||||||
|
actions: [
|
||||||
|
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
|
||||||
|
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
|
||||||
|
|
||||||
|
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
|
||||||
|
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||||
|
const now = new Date();
|
||||||
|
return factory(
|
||||||
|
`analysis-${now.getTime().toString(36)}-${instance}`,
|
||||||
|
now.toLocaleTimeString("zh-CN", { hour12: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFixtureTimestamp(clock: string) {
|
||||||
|
const [hours = 0, minutes = 0, seconds = 0] = clock.split(":").map(Number);
|
||||||
|
const value = new Date();
|
||||||
|
value.setHours(hours, minutes, seconds, 0);
|
||||||
|
return value.getTime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
Database,
|
||||||
|
FileSearch,
|
||||||
|
Minimize2,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { showMapNotice } from "@/features/map/core";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
|
||||||
|
ARTIFACT_ICON_BUTTON_CLASS_NAME,
|
||||||
|
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
|
||||||
|
} from "./artifact-view-control-styles";
|
||||||
|
import type {
|
||||||
|
AnalysisArtifact,
|
||||||
|
AnalysisArtifactAction,
|
||||||
|
AnalysisBlock,
|
||||||
|
AnalysisMetric
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
type AnalysisArtifactWorkspaceProps = {
|
||||||
|
artifact: AnalysisArtifact;
|
||||||
|
onCollapse: () => void;
|
||||||
|
onDestroy: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||||
|
|
||||||
|
export function AnalysisArtifactWorkspace({
|
||||||
|
artifact,
|
||||||
|
onCollapse,
|
||||||
|
onDestroy
|
||||||
|
}: AnalysisArtifactWorkspaceProps) {
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||||
|
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||||||
|
const evidenceBlocks = artifact.blocks.filter(
|
||||||
|
(block): block is EvidenceBlock => block.kind !== "metric-grid"
|
||||||
|
);
|
||||||
|
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedBlockId(null);
|
||||||
|
}, [artifact.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedBlock) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setSelectedBlockId(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [selectedBlock]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
aria-label={`${artifact.title}分析成果`}
|
||||||
|
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||||||
|
lang="zh-CN"
|
||||||
|
>
|
||||||
|
<ArtifactHeader artifact={artifact} />
|
||||||
|
|
||||||
|
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||||||
|
<section aria-labelledby="artifact-summary-heading">
|
||||||
|
<div className="mb-3 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
|
||||||
|
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
|
||||||
|
</div>
|
||||||
|
{metricBlock?.kind === "metric-grid" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
|
||||||
|
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
|
||||||
|
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-12 gap-3.5">
|
||||||
|
{evidenceBlocks.map((block) => (
|
||||||
|
<AnalysisBlockView
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
selected={block.id === selectedBlockId}
|
||||||
|
onSelect={() => setSelectedBlockId(block.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ArtifactProvenance artifact={artifact} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArtifactActionBar
|
||||||
|
artifact={artifact}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onDestroy={onDestroy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedBlock ? (
|
||||||
|
<ArtifactDetailDrawer
|
||||||
|
artifact={artifact}
|
||||||
|
block={selectedBlock}
|
||||||
|
onClose={() => setSelectedBlockId(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||||
|
return (
|
||||||
|
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
|
||||||
|
<Sparkles size={14} aria-hidden="true" />
|
||||||
|
Agent Artifact
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{artifactTypeLabel(artifact.type)}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>R{artifact.revision}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
|
||||||
|
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
<LifecycleBadge lifecycle={artifact.lifecycle} />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<Clock3 size={13} aria-hidden="true" />
|
||||||
|
{artifact.generatedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
|
||||||
|
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
|
||||||
|
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisBlockView({
|
||||||
|
block,
|
||||||
|
selected,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
block: EvidenceBlock;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const spanClass = block.span === 5
|
||||||
|
? "col-span-12 2xl:col-span-5"
|
||||||
|
: block.span === 7
|
||||||
|
? "col-span-12 2xl:col-span-7"
|
||||||
|
: "col-span-12";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby={`${block.id}-title`}
|
||||||
|
className={cn(
|
||||||
|
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
|
||||||
|
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||||||
|
spanClass,
|
||||||
|
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
|
||||||
|
aria-label={`查看${block.title}详情`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
||||||
|
<FileSearch size={16} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
|
||||||
|
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-slate-100 p-4">
|
||||||
|
<AnalysisBlockBody block={block} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
|
||||||
|
if (block.kind === "chart") {
|
||||||
|
return (
|
||||||
|
<div className="h-[258px] min-h-0">
|
||||||
|
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "process-flow") {
|
||||||
|
return (
|
||||||
|
<ol className="space-y-0" aria-label={block.title}>
|
||||||
|
{block.nodes.map((node, index) => (
|
||||||
|
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||||||
|
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
|
||||||
|
<span className={cn(
|
||||||
|
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||||||
|
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||||||
|
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||||||
|
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||||||
|
)}>
|
||||||
|
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 pt-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||||||
|
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "data-table") {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||||||
|
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, index) => (
|
||||||
|
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||||||
|
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||||||
|
return (
|
||||||
|
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
|
||||||
|
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
|
||||||
|
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
|
||||||
|
<Database size={14} aria-hidden="true" />
|
||||||
|
数据来源
|
||||||
|
</h3>
|
||||||
|
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
|
||||||
|
{artifact.sources.map((source) => (
|
||||||
|
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
|
||||||
|
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" />
|
||||||
|
分析限制
|
||||||
|
</h3>
|
||||||
|
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
|
||||||
|
{artifact.limitations.map((item) => (
|
||||||
|
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactDetailDrawer({
|
||||||
|
artifact,
|
||||||
|
block,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
artifact: AnalysisArtifact;
|
||||||
|
block: EvidenceBlock;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const blockFacts = getEvidenceBlockFacts(block);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="证据详情"
|
||||||
|
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
|
||||||
|
>
|
||||||
|
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
|
||||||
|
<span className="uppercase tracking-[0.12em]">证据详情</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||||
|
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭证据详情"
|
||||||
|
onClick={onClose}
|
||||||
|
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5 px-5 py-5">
|
||||||
|
<section aria-labelledby="evidence-purpose-heading">
|
||||||
|
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500">证据说明</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||||
|
{evidenceBlockDescription(block)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500">数据概览</h4>
|
||||||
|
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-2 divide-y divide-slate-200">
|
||||||
|
{blockFacts.map((fact) => (
|
||||||
|
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
|
||||||
|
<dt className="text-slate-500">{fact.label}</dt>
|
||||||
|
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
|
||||||
|
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500">分析上下文</h4>
|
||||||
|
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
|
||||||
|
<dt className="text-slate-500">分析时间</dt>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
|
||||||
|
<dt className="text-slate-500">分析范围</dt>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
|
||||||
|
<dt className="text-slate-500">综合置信度</dt>
|
||||||
|
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceBlockDescription(block: EvidenceBlock) {
|
||||||
|
return "description" in block
|
||||||
|
? block.description
|
||||||
|
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceBlockKindLabel(block: EvidenceBlock) {
|
||||||
|
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
|
||||||
|
if (block.kind === "process-flow") return "流程证据";
|
||||||
|
if (block.kind === "data-table") return "明细数据";
|
||||||
|
return "分析说明";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
|
||||||
|
if (block.kind === "chart") {
|
||||||
|
return [
|
||||||
|
{ label: "数据序列", value: `${block.series.length} 组` },
|
||||||
|
{ label: "时间 / 类别点", value: `${block.categories.length} 个` },
|
||||||
|
{ label: "计量单位", value: block.unit }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (block.kind === "data-table") {
|
||||||
|
return [
|
||||||
|
{ label: "证据记录", value: `${block.rows.length} 条` },
|
||||||
|
{ label: "数据字段", value: `${block.columns.length} 个` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (block.kind === "process-flow") {
|
||||||
|
return [
|
||||||
|
{ label: "流程步骤", value: `${block.nodes.length} 项` },
|
||||||
|
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length} 项` },
|
||||||
|
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length} 项` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ label: "分析段落", value: `${block.paragraphs.length} 段` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
|
||||||
|
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
return `${formatter.format(new Date(range.start))} 至 ${formatter.format(new Date(range.end))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactActionBar({
|
||||||
|
artifact,
|
||||||
|
onCollapse,
|
||||||
|
onDestroy
|
||||||
|
}: {
|
||||||
|
artifact: AnalysisArtifact;
|
||||||
|
onCollapse: () => void;
|
||||||
|
onDestroy: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||||
|
<span />
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||||||
|
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
|
||||||
|
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
|
||||||
|
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||||
|
return (
|
||||||
|
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||||||
|
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
|
||||||
|
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
|
||||||
|
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
|
||||||
|
if (type === "diagnosis") return "异常诊断";
|
||||||
|
if (type === "simulation") return "方案模拟";
|
||||||
|
return "指标分析";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
|
||||||
|
if (relation === "required") return "强关联";
|
||||||
|
if (relation === "optional") return "可选";
|
||||||
|
return "无空间依赖";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||||
|
return {
|
||||||
|
animationDuration: 260,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
color: block.series.map((series) => series.color),
|
||||||
|
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||||||
|
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||||||
|
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||||||
|
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||||
|
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||||
|
series: block.series.map((series) => ({
|
||||||
|
name: series.name,
|
||||||
|
type: series.type ?? block.chartType,
|
||||||
|
data: series.values,
|
||||||
|
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||||||
|
symbol: "circle",
|
||||||
|
symbolSize: 6,
|
||||||
|
barMaxWidth: 24,
|
||||||
|
lineStyle: { width: 2.5 },
|
||||||
|
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||||||
|
if (tone === "danger") return "bg-rose-500";
|
||||||
|
if (tone === "warning") return "bg-amber-500";
|
||||||
|
if (tone === "success") return "bg-emerald-500";
|
||||||
|
if (tone === "info") return "bg-blue-600";
|
||||||
|
return "bg-slate-400";
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import {
|
||||||
|
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
|
||||||
|
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
|
||||||
|
} from "./artifact-view-control-styles";
|
||||||
|
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
|
||||||
|
|
||||||
|
export function ArtifactPeekBar({
|
||||||
|
artifact,
|
||||||
|
surfaceMode,
|
||||||
|
onOpen,
|
||||||
|
onDestroy
|
||||||
|
}: {
|
||||||
|
artifact: AnalysisArtifact;
|
||||||
|
surfaceMode: WorkbenchSurfaceMode;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDestroy: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Agent 成果预览"
|
||||||
|
className={cn(
|
||||||
|
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
|
||||||
|
"left-1/2 -translate-x-1/2",
|
||||||
|
surfaceMode === "map_split" && "2xl:left-[25%]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="material-tone-agent hidden h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700 sm:grid">
|
||||||
|
<FileChartColumnIncreasing size={21} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
|
||||||
|
<Sparkles size={12} aria-hidden="true" />
|
||||||
|
新分析成果 · R{artifact.revision}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
|
||||||
|
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} aria-hidden="true" />
|
||||||
|
打开成果
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="销毁预览成果"
|
||||||
|
title="销毁预览成果"
|
||||||
|
onClick={onDestroy}
|
||||||
|
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
|
||||||
|
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
|
||||||
|
|
||||||
|
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
|
||||||
|
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
|
||||||
|
|
||||||
|
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
|
||||||
|
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
|
||||||
|
|
||||||
|
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
|
||||||
|
|
||||||
|
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||||
|
import {
|
||||||
|
collapseActiveArtifact,
|
||||||
|
createInitialArtifactWorkspaceState,
|
||||||
|
destroyActiveArtifact,
|
||||||
|
destroyPendingArtifact,
|
||||||
|
openPendingArtifact,
|
||||||
|
previewAnalysisArtifact,
|
||||||
|
requestArtifactView,
|
||||||
|
resolveWorkbenchSurfaceMode,
|
||||||
|
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||||
|
type ArtifactRequestedView
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
|
||||||
|
const [state, setState] = useState(createInitialArtifactWorkspaceState);
|
||||||
|
const fixtureIndexRef = useRef(0);
|
||||||
|
const fixtureInstanceRef = useRef(0);
|
||||||
|
|
||||||
|
const createTestArtifactPreview = useCallback(() => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(
|
||||||
|
fixtureIndexRef.current,
|
||||||
|
fixtureInstanceRef.current
|
||||||
|
);
|
||||||
|
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
|
||||||
|
fixtureInstanceRef.current += 1;
|
||||||
|
setState((current) => previewAnalysisArtifact(current, artifact));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openPreview = useCallback(() => {
|
||||||
|
setState(openPendingArtifact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const collapseArtifact = useCallback(() => {
|
||||||
|
setState(collapseActiveArtifact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const destroyPreview = useCallback(() => {
|
||||||
|
setState(destroyPendingArtifact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const destroyArtifact = useCallback(() => {
|
||||||
|
setState(destroyActiveArtifact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setView = useCallback((view: ArtifactRequestedView) => {
|
||||||
|
setState((current) => requestArtifactView(current, view));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const surfaceMode = useMemo(
|
||||||
|
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
|
||||||
|
[businessWorkspaceWidth, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
surfaceMode,
|
||||||
|
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||||
|
createTestArtifactPreview,
|
||||||
|
openPreview,
|
||||||
|
collapseArtifact,
|
||||||
|
destroyPreview,
|
||||||
|
destroyArtifact,
|
||||||
|
setView
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/shared/ui/cn";
|
||||||
|
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||||
|
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||||
|
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||||
|
import {
|
||||||
|
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||||
|
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||||
|
} from "./artifact-view-control-styles";
|
||||||
|
import type {
|
||||||
|
AnalysisArtifact,
|
||||||
|
ArtifactRequestedView,
|
||||||
|
WorkbenchSurfaceMode
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
type WorkbenchMainFrameProps = {
|
||||||
|
mapContent: ReactNode;
|
||||||
|
mapOverlay?: ReactNode;
|
||||||
|
timelineContent: ReactNode;
|
||||||
|
timelineMode: "compact" | "expanded" | "summary";
|
||||||
|
timelineBottom: number;
|
||||||
|
activeArtifact: AnalysisArtifact | null;
|
||||||
|
pendingArtifact: AnalysisArtifact | null;
|
||||||
|
surfaceMode: WorkbenchSurfaceMode;
|
||||||
|
mapSplitAvailable: boolean;
|
||||||
|
onOpenPendingArtifact: () => void;
|
||||||
|
onDestroyPendingArtifact: () => void;
|
||||||
|
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||||
|
onCollapseArtifact: () => void;
|
||||||
|
onDestroyArtifact: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkbenchMainFrame({
|
||||||
|
mapContent,
|
||||||
|
mapOverlay,
|
||||||
|
timelineContent,
|
||||||
|
timelineMode,
|
||||||
|
timelineBottom,
|
||||||
|
activeArtifact,
|
||||||
|
pendingArtifact,
|
||||||
|
surfaceMode,
|
||||||
|
mapSplitAvailable,
|
||||||
|
onOpenPendingArtifact,
|
||||||
|
onDestroyPendingArtifact,
|
||||||
|
onSetArtifactView,
|
||||||
|
onCollapseArtifact,
|
||||||
|
onDestroyArtifact
|
||||||
|
}: WorkbenchMainFrameProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
const showMap = surfaceMode !== "focus";
|
||||||
|
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
|
||||||
|
const showArtifactViewControl = Boolean(
|
||||||
|
activeArtifact &&
|
||||||
|
activeArtifact.mapRelation !== "none"
|
||||||
|
);
|
||||||
|
const mapOnly = surfaceMode === "map_only";
|
||||||
|
const mapSplit = surfaceMode === "map_split";
|
||||||
|
const viewControlLabel = mapOnly
|
||||||
|
? "返回分析成果"
|
||||||
|
: mapSplit
|
||||||
|
? "专注分析"
|
||||||
|
: "显示地图";
|
||||||
|
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
|
||||||
|
const timelineTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
const viewControlTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
const artifactTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
|
||||||
|
data-testid="workbench-main-frame"
|
||||||
|
data-timeline-mode={timelineMode}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="main-workspace"
|
||||||
|
data-layout-mode={surfaceMode}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||||
|
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||||
|
surfaceMode !== "map_split" && "grid-cols-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
id="workspace-map"
|
||||||
|
aria-label="供水管网地图工作区"
|
||||||
|
aria-hidden={!showMap}
|
||||||
|
className={cn(
|
||||||
|
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||||
|
!showMap && "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mapContent}
|
||||||
|
{mapOverlay}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{activeArtifact ? (
|
||||||
|
<motion.section
|
||||||
|
key={activeArtifact.id}
|
||||||
|
id="artifact-workspace"
|
||||||
|
aria-label={`${activeArtifact.title}工作区`}
|
||||||
|
aria-hidden={!showArtifact}
|
||||||
|
className={cn(
|
||||||
|
"z-30 min-h-0 min-w-0 overflow-hidden",
|
||||||
|
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
|
||||||
|
)}
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||||
|
animate={showArtifact
|
||||||
|
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 8,
|
||||||
|
scale: 0.994,
|
||||||
|
transitionEnd: { visibility: "hidden" }
|
||||||
|
}}
|
||||||
|
exit={prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, y: 10, scale: 0.992 }}
|
||||||
|
transition={artifactTransition}
|
||||||
|
>
|
||||||
|
<AnalysisArtifactWorkspace
|
||||||
|
artifact={activeArtifact}
|
||||||
|
onCollapse={onCollapseArtifact}
|
||||||
|
onDestroy={onDestroyArtifact}
|
||||||
|
/>
|
||||||
|
</motion.section>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{pendingArtifact ? (
|
||||||
|
<ArtifactPeekBar
|
||||||
|
artifact={pendingArtifact}
|
||||||
|
surfaceMode={surfaceMode}
|
||||||
|
onOpen={onOpenPendingArtifact}
|
||||||
|
onDestroy={onDestroyPendingArtifact}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{showArtifactViewControl ? (
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
|
||||||
|
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||||
|
)}
|
||||||
|
initial={prefersReducedMotion
|
||||||
|
? false
|
||||||
|
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
||||||
|
transition={viewControlTransition}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={viewControlLabel}
|
||||||
|
onClick={() => onSetArtifactView(
|
||||||
|
surfaceMode === "focus"
|
||||||
|
? mapSplitAvailable ? "map_split" : "map_only"
|
||||||
|
: "focus"
|
||||||
|
)}
|
||||||
|
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
key={surfaceMode}
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
|
||||||
|
transition={viewControlTransition}
|
||||||
|
>
|
||||||
|
{mapOnly ? (
|
||||||
|
<ArrowLeft size={13} aria-hidden="true" />
|
||||||
|
) : mapSplit ? (
|
||||||
|
<Focus size={13} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<MapIcon size={13} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{viewControlLabel}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
bottom: timelineBottom,
|
||||||
|
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
||||||
|
right: 12
|
||||||
|
}}
|
||||||
|
transition={timelineTransition}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{timelineContent ? (
|
||||||
|
<motion.div
|
||||||
|
key="operational-timeline"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
|
||||||
|
transition={timelineTransition}
|
||||||
|
>
|
||||||
|
{timelineContent}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||||
|
import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace-context";
|
||||||
|
|
||||||
|
describe("workspace context", () => {
|
||||||
|
it("keeps operational time independent from artifact analytical time", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const withArtifact = reduceWorkspaceContext(createInitialWorkspaceContext(), {
|
||||||
|
type: "sync-artifact",
|
||||||
|
artifact
|
||||||
|
});
|
||||||
|
const selectedEvent = reduceWorkspaceContext(withArtifact, {
|
||||||
|
type: "select-operational-event",
|
||||||
|
eventId: "event-1",
|
||||||
|
cursorTime: "2026-08-19T10:30:00.000Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectedEvent.analytical.timeRange).toEqual(artifact.analyticalTimeRange);
|
||||||
|
expect(selectedEvent.operational.cursorTime).toBe("2026-08-19T10:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates analytical context without moving the operational cursor", () => {
|
||||||
|
const initial = createInitialWorkspaceContext(new Date("2026-08-19T08:00:00.000Z"));
|
||||||
|
const cursorTime = initial.operational.cursorTime;
|
||||||
|
const withArtifact = reduceWorkspaceContext(initial, {
|
||||||
|
type: "sync-artifact",
|
||||||
|
artifact: createAnalysisArtifactFixture(1, 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withArtifact.operational.cursorTime).toBe(cursorTime);
|
||||||
|
expect(withArtifact.analytical.artifactId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears selection while retaining cursor until returning to now", () => {
|
||||||
|
const initial = createInitialWorkspaceContext();
|
||||||
|
const selected = reduceWorkspaceContext(initial, {
|
||||||
|
type: "select-operational-event",
|
||||||
|
eventId: "event-2",
|
||||||
|
cursorTime: "2026-08-19T13:10:00.000Z"
|
||||||
|
});
|
||||||
|
const cleared = reduceWorkspaceContext(selected, { type: "clear-operational-event" });
|
||||||
|
|
||||||
|
expect(cleared.operational.selectedEventId).toBeNull();
|
||||||
|
expect(cleared.operational.cursorTime).toBe("2026-08-19T13:10:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { AnalysisArtifact } from "./workspace-model";
|
||||||
|
|
||||||
|
export type WorkspaceTimeRange = { start: string; end: string };
|
||||||
|
|
||||||
|
export type WorkspaceContext = {
|
||||||
|
spatial: {
|
||||||
|
featureId: string | null;
|
||||||
|
sourceId: string | null;
|
||||||
|
};
|
||||||
|
operational: {
|
||||||
|
date: string;
|
||||||
|
cursorTime: string;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
};
|
||||||
|
analytical: {
|
||||||
|
artifactId: string | null;
|
||||||
|
timeRange: WorkspaceTimeRange | null;
|
||||||
|
evidenceId: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceContextAction =
|
||||||
|
| { type: "select-operational-event"; eventId: string; cursorTime: string }
|
||||||
|
| { type: "clear-operational-event" }
|
||||||
|
| { type: "return-operational-now"; now: string }
|
||||||
|
| { type: "select-spatial-feature"; featureId: string; sourceId: string }
|
||||||
|
| { type: "clear-spatial-feature" }
|
||||||
|
| { type: "sync-artifact"; artifact: AnalysisArtifact | null }
|
||||||
|
| { type: "select-evidence"; evidenceId: string | null };
|
||||||
|
|
||||||
|
export function createInitialWorkspaceContext(now = new Date()): WorkspaceContext {
|
||||||
|
return {
|
||||||
|
spatial: { featureId: null, sourceId: null },
|
||||||
|
operational: {
|
||||||
|
date: toLocalDateKey(now),
|
||||||
|
cursorTime: now.toISOString(),
|
||||||
|
selectedEventId: null
|
||||||
|
},
|
||||||
|
analytical: { artifactId: null, timeRange: null, evidenceId: null }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceWorkspaceContext(
|
||||||
|
state: WorkspaceContext,
|
||||||
|
action: WorkspaceContextAction
|
||||||
|
): WorkspaceContext {
|
||||||
|
switch (action.type) {
|
||||||
|
case "select-operational-event":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
operational: {
|
||||||
|
...state.operational,
|
||||||
|
cursorTime: action.cursorTime,
|
||||||
|
selectedEventId: action.eventId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "clear-operational-event":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
operational: { ...state.operational, selectedEventId: null }
|
||||||
|
};
|
||||||
|
case "return-operational-now":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
operational: {
|
||||||
|
date: toLocalDateKey(new Date(action.now)),
|
||||||
|
cursorTime: action.now,
|
||||||
|
selectedEventId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "select-spatial-feature":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
spatial: { featureId: action.featureId, sourceId: action.sourceId }
|
||||||
|
};
|
||||||
|
case "clear-spatial-feature":
|
||||||
|
return { ...state, spatial: { featureId: null, sourceId: null } };
|
||||||
|
case "sync-artifact":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analytical: action.artifact
|
||||||
|
? {
|
||||||
|
artifactId: action.artifact.id,
|
||||||
|
timeRange: action.artifact.analyticalTimeRange,
|
||||||
|
evidenceId: null
|
||||||
|
}
|
||||||
|
: { artifactId: null, timeRange: null, evidenceId: null }
|
||||||
|
};
|
||||||
|
case "select-evidence":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
analytical: { ...state.analytical, evidenceId: action.evidenceId }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateKey(value: Date) {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||||
|
import {
|
||||||
|
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||||
|
collapseActiveArtifact,
|
||||||
|
createInitialArtifactWorkspaceState,
|
||||||
|
destroyActiveArtifact,
|
||||||
|
destroyPendingArtifact,
|
||||||
|
openPendingArtifact,
|
||||||
|
previewAnalysisArtifact,
|
||||||
|
requestArtifactView,
|
||||||
|
resolveWorkbenchSurfaceMode
|
||||||
|
} from "./workspace-model";
|
||||||
|
|
||||||
|
describe("artifact workspace state", () => {
|
||||||
|
it("previews generated artifacts without taking over the workspace", () => {
|
||||||
|
const initial = createInitialArtifactWorkspaceState();
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const previewed = previewAnalysisArtifact(initial, artifact);
|
||||||
|
|
||||||
|
expect(previewed.pendingArtifact).toBe(artifact);
|
||||||
|
expect(previewed.activeArtifact).toBeNull();
|
||||||
|
expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a map-related artifact in split mode only when enough width exists", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(opened.activeArtifact).toBe(artifact);
|
||||||
|
expect(opened.pendingArtifact).toBeNull();
|
||||||
|
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split");
|
||||||
|
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-spatial artifacts focused at every desktop width", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(1, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(artifact.mapRelation).toBe("none");
|
||||||
|
expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
|
||||||
|
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports explicit focus and map restoration for spatial artifacts", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(2, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
const focused = requestArtifactView(opened, "focus");
|
||||||
|
const restored = requestArtifactView(focused, "map_split");
|
||||||
|
|
||||||
|
expect(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus");
|
||||||
|
expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a map-only page while a narrow Artifact remains active", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
const mapOnly = requestArtifactView(opened, "map_only");
|
||||||
|
|
||||||
|
expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only");
|
||||||
|
expect(mapOnly.activeArtifact).toBe(artifact);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the active artifact back into a peek", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
|
||||||
|
);
|
||||||
|
const collapsed = collapseActiveArtifact(opened);
|
||||||
|
|
||||||
|
expect(collapsed.activeArtifact).toBeNull();
|
||||||
|
expect(collapsed.pendingArtifact).toBe(artifact);
|
||||||
|
expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the active artifact only after the new preview is opened", () => {
|
||||||
|
const first = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const second = createAnalysisArtifactFixture(2, 1);
|
||||||
|
const opened = openPendingArtifact(
|
||||||
|
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), first)
|
||||||
|
);
|
||||||
|
const withNewPreview = previewAnalysisArtifact(opened, second);
|
||||||
|
|
||||||
|
expect(withNewPreview.activeArtifact).toBe(first);
|
||||||
|
expect(withNewPreview.pendingArtifact).toBe(second);
|
||||||
|
expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys pending and active artifacts independently", () => {
|
||||||
|
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||||
|
const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
|
||||||
|
const opened = openPendingArtifact(previewed);
|
||||||
|
|
||||||
|
expect(destroyPendingArtifact(previewed).pendingArtifact).toBeNull();
|
||||||
|
expect(destroyActiveArtifact(opened).activeArtifact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
export const ARTIFACT_PANE_MIN_WIDTH = 640;
|
||||||
|
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
|
||||||
|
|
||||||
|
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||||
|
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||||
|
export type ArtifactViewMode = "map_split" | "focus";
|
||||||
|
export type ArtifactRequestedView = ArtifactViewMode | "map_only";
|
||||||
|
export type ArtifactMapRelation = "required" | "optional" | "none";
|
||||||
|
export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
|
||||||
|
|
||||||
|
export type AnalysisMetric = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
tone: "neutral" | "info" | "success" | "warning" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisChartSeries = {
|
||||||
|
name: string;
|
||||||
|
values: number[];
|
||||||
|
color: string;
|
||||||
|
type?: "line" | "bar";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisBlock =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "metric-grid";
|
||||||
|
title: string;
|
||||||
|
span: 12;
|
||||||
|
metrics: AnalysisMetric[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "chart";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
chartType: "line" | "bar";
|
||||||
|
categories: string[];
|
||||||
|
unit: string;
|
||||||
|
series: AnalysisChartSeries[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "process-flow";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
status: "complete" | "active" | "pending";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "data-table";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
columns: Array<{ key: string; label: string; numeric?: boolean }>;
|
||||||
|
rows: Array<Record<string, string | number>>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "narrative";
|
||||||
|
title: string;
|
||||||
|
span: 5 | 7 | 12;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisArtifactAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone: "primary" | "neutral" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisArtifact = {
|
||||||
|
id: string;
|
||||||
|
type: ArtifactType;
|
||||||
|
lifecycle: ArtifactLifecycle;
|
||||||
|
revision: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
summary: string;
|
||||||
|
scope: string;
|
||||||
|
confidence: string;
|
||||||
|
generatedAt: string;
|
||||||
|
analyticalTimeRange: { start: string; end: string };
|
||||||
|
preferredView: ArtifactViewMode;
|
||||||
|
mapRelation: ArtifactMapRelation;
|
||||||
|
blocks: AnalysisBlock[];
|
||||||
|
sources: string[];
|
||||||
|
limitations: string[];
|
||||||
|
actions: AnalysisArtifactAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtifactWorkspaceState = {
|
||||||
|
pendingArtifact: AnalysisArtifact | null;
|
||||||
|
activeArtifact: AnalysisArtifact | null;
|
||||||
|
requestedView: ArtifactRequestedView;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
|
||||||
|
return {
|
||||||
|
pendingArtifact: null,
|
||||||
|
activeArtifact: null,
|
||||||
|
requestedView: "map_split"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewAnalysisArtifact(
|
||||||
|
state: ArtifactWorkspaceState,
|
||||||
|
artifact: AnalysisArtifact
|
||||||
|
): ArtifactWorkspaceState {
|
||||||
|
return { ...state, pendingArtifact: artifact };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
|
if (!state.pendingArtifact) return state;
|
||||||
|
return {
|
||||||
|
pendingArtifact: null,
|
||||||
|
activeArtifact: state.pendingArtifact,
|
||||||
|
requestedView: getPreferredArtifactView(state.pendingArtifact)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
|
if (!state.activeArtifact) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingArtifact: state.activeArtifact,
|
||||||
|
activeArtifact: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
|
return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
|
||||||
|
return state.activeArtifact
|
||||||
|
? { ...state, activeArtifact: null, requestedView: "map_split" }
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestArtifactView(
|
||||||
|
state: ArtifactWorkspaceState,
|
||||||
|
view: ArtifactRequestedView
|
||||||
|
): ArtifactWorkspaceState {
|
||||||
|
if (!state.activeArtifact) return state;
|
||||||
|
return { ...state, requestedView: view };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkbenchSurfaceMode(
|
||||||
|
state: ArtifactWorkspaceState,
|
||||||
|
businessWorkspaceWidth: number
|
||||||
|
): WorkbenchSurfaceMode {
|
||||||
|
const artifact = state.activeArtifact;
|
||||||
|
if (!artifact) return "default";
|
||||||
|
if (state.requestedView === "map_only") return "map_only";
|
||||||
|
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
|
||||||
|
return businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
|
||||||
|
return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
|
||||||
|
}
|
||||||
+19
-9
@@ -1,23 +1,33 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
import { App } from "@/app/app";
|
import { AuthStartupScreen } from "@/app/auth-startup-screen";
|
||||||
import { env } from "@/shared/config/env";
|
import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
|
||||||
import "@/styles.css";
|
import "@/styles.css";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
|
|
||||||
async function prepareMocks() {
|
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||||
if (!env.TJWATER_ENABLE_MSW) return;
|
|
||||||
|
root.render(<AuthStartupScreen />);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [{ App }, { env }] = await Promise.all([
|
||||||
|
import("@/app/app"),
|
||||||
|
import("@/shared/config/env")
|
||||||
|
]);
|
||||||
|
if (env.TJWATER_ENABLE_MSW) {
|
||||||
const { worker } = await import("@/mocks/browser");
|
const { worker } = await import("@/mocks/browser");
|
||||||
await worker.start({ onUnhandledRequest: "bypass" });
|
await worker.start({ onUnhandledRequest: "bypass" });
|
||||||
}
|
}
|
||||||
|
const authentication = await initializeAuthentication(env);
|
||||||
await prepareMocks();
|
root.render(
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App authentication={authentication} />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize authentication", error);
|
||||||
|
root.render(<AuthStartupScreen error />);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { parseRuntimeConfig } from "@/shared/config/env";
|
||||||
|
import {
|
||||||
|
initializeAuthentication,
|
||||||
|
toAuthenticatedUser,
|
||||||
|
toKeycloakConfig
|
||||||
|
} from "./keycloak-auth";
|
||||||
|
|
||||||
|
const requiredConfig = parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/auth/realms/tjwater",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Keycloak authentication", () => {
|
||||||
|
it("derives the Keycloak server and Realm from the issuer", () => {
|
||||||
|
expect(toKeycloakConfig(requiredConfig.TJWATER_KEYCLOAK_ISSUER, "next-tjwater")).toEqual({
|
||||||
|
url: "https://auth.example.test/auth",
|
||||||
|
realm: "tjwater",
|
||||||
|
clientId: "next-tjwater"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes the public SPA flow and exposes the refreshed token", async () => {
|
||||||
|
const client = {
|
||||||
|
token: "access-token",
|
||||||
|
idTokenParsed: {
|
||||||
|
sub: "user-1",
|
||||||
|
name: "张调度",
|
||||||
|
email: "operator@example.test"
|
||||||
|
},
|
||||||
|
init: vi.fn().mockResolvedValue(true),
|
||||||
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
|
logout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateToken: vi.fn().mockResolvedValue(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||||
|
|
||||||
|
expect(client.init).toHaveBeenCalledWith({
|
||||||
|
onLoad: "login-required",
|
||||||
|
flow: "standard",
|
||||||
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
|
});
|
||||||
|
expect(authentication.user).toEqual({
|
||||||
|
name: "张调度",
|
||||||
|
role: "operator@example.test"
|
||||||
|
});
|
||||||
|
await expect(authentication.getAccessToken()).resolves.toBe("access-token");
|
||||||
|
expect(client.updateToken).toHaveBeenCalledWith(30);
|
||||||
|
|
||||||
|
await authentication.logout?.();
|
||||||
|
expect(client.logout).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stable user claim fallbacks", () => {
|
||||||
|
expect(toAuthenticatedUser({ sub: "user-2", preferred_username: "dispatcher" })).toEqual({
|
||||||
|
name: "dispatcher",
|
||||||
|
role: "统一认证用户"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to Keycloak instead of using a token after refresh failure", async () => {
|
||||||
|
const refreshError = new Error("refresh failed");
|
||||||
|
const client = {
|
||||||
|
token: "expired-token",
|
||||||
|
tokenParsed: { sub: "user-3" },
|
||||||
|
init: vi.fn().mockResolvedValue(true),
|
||||||
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
|
logout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateToken: vi.fn().mockRejectedValue(refreshError)
|
||||||
|
};
|
||||||
|
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||||
|
|
||||||
|
await expect(authentication.getAccessToken()).rejects.toBe(refreshError);
|
||||||
|
expect(client.login).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps authentication inert when explicitly disabled", async () => {
|
||||||
|
const createClient = vi.fn();
|
||||||
|
const authentication = await initializeAuthentication(
|
||||||
|
parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" }),
|
||||||
|
createClient
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(authentication.enabled).toBe(false);
|
||||||
|
await expect(authentication.getAccessToken()).resolves.toBeNull();
|
||||||
|
expect(createClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Keycloak, {
|
||||||
|
type KeycloakConfig,
|
||||||
|
type KeycloakInitOptions,
|
||||||
|
type KeycloakLoginOptions,
|
||||||
|
type KeycloakLogoutOptions,
|
||||||
|
type KeycloakTokenParsed
|
||||||
|
} from "keycloak-js";
|
||||||
|
import type { RuntimeConfig } from "@/shared/config/env";
|
||||||
|
|
||||||
|
const TOKEN_MIN_VALIDITY_SECONDS = 30;
|
||||||
|
|
||||||
|
export type AuthenticatedUser = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessTokenProvider = () => Promise<string | null>;
|
||||||
|
|
||||||
|
export type Authentication = {
|
||||||
|
enabled: boolean;
|
||||||
|
user: AuthenticatedUser | null;
|
||||||
|
getAccessToken: AccessTokenProvider;
|
||||||
|
logout?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KeycloakClient = {
|
||||||
|
token?: string;
|
||||||
|
tokenParsed?: KeycloakTokenParsed;
|
||||||
|
idTokenParsed?: KeycloakTokenParsed;
|
||||||
|
onTokenExpired?: () => void;
|
||||||
|
init: (options: KeycloakInitOptions) => Promise<boolean>;
|
||||||
|
login: (options?: KeycloakLoginOptions) => Promise<void>;
|
||||||
|
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
|
||||||
|
updateToken: (minValidity: number) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
|
||||||
|
|
||||||
|
export async function initializeAuthentication(
|
||||||
|
config: RuntimeConfig,
|
||||||
|
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
|
||||||
|
): Promise<Authentication> {
|
||||||
|
if (config.TJWATER_AUTH_MODE === "disabled") {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
user: null,
|
||||||
|
getAccessToken: async () => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloak = createClient(
|
||||||
|
toKeycloakConfig(
|
||||||
|
config.TJWATER_KEYCLOAK_ISSUER,
|
||||||
|
config.TJWATER_KEYCLOAK_CLIENT_ID
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const redirectUri = `${window.location.origin}/`;
|
||||||
|
const login = () => keycloak.login({ redirectUri });
|
||||||
|
const authenticated = await keycloak.init({
|
||||||
|
onLoad: "login-required",
|
||||||
|
flow: "standard",
|
||||||
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await login();
|
||||||
|
throw new Error("Keycloak authentication was not completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
try {
|
||||||
|
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
|
||||||
|
} catch (error) {
|
||||||
|
await login();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
keycloak.onTokenExpired = () => {
|
||||||
|
void refreshAccessToken().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
|
||||||
|
getAccessToken: async () => {
|
||||||
|
await refreshAccessToken();
|
||||||
|
if (!keycloak.token) {
|
||||||
|
throw new Error("Keycloak access token is unavailable");
|
||||||
|
}
|
||||||
|
return keycloak.token;
|
||||||
|
},
|
||||||
|
logout: () => keycloak.logout({ redirectUri })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
|
||||||
|
const url = new URL(issuer);
|
||||||
|
const realmMarker = "/realms/";
|
||||||
|
const markerIndex = url.pathname.lastIndexOf(realmMarker);
|
||||||
|
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
|
||||||
|
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${url.origin}${serverPath}`,
|
||||||
|
realm,
|
||||||
|
clientId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
|
||||||
|
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: readClaim(token, "email") ?? "统一认证用户"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
|
||||||
|
const value = token?.[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
@@ -6,17 +6,25 @@ describe("runtime frontend configuration", () => {
|
|||||||
it("uses container-provided values without relying on Vite build variables", () => {
|
it("uses container-provided values without relying on Vite build variables", () => {
|
||||||
expect(
|
expect(
|
||||||
parseRuntimeConfig({
|
parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "true",
|
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||||
TJWATER_ENABLE_DEV_PANEL: true,
|
TJWATER_ENABLE_DEV_PANEL: true,
|
||||||
TJWATER_ENABLE_MSW: false
|
TJWATER_ENABLE_MSW: false
|
||||||
@@ -24,12 +32,18 @@ describe("runtime frontend configuration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies typed defaults for optional feature flags", () => {
|
it("applies typed defaults for optional feature flags", () => {
|
||||||
expect(parseRuntimeConfig({})).toMatchObject({
|
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_ENABLE_DEV_PANEL: false,
|
TJWATER_ENABLE_DEV_PANEL: false,
|
||||||
TJWATER_ENABLE_MSW: false
|
TJWATER_ENABLE_MSW: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires a Realm issuer when authentication is enabled", () => {
|
||||||
|
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "required" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects invalid runtime URLs before the application starts", () => {
|
it("rejects invalid runtime URLs before the application starts", () => {
|
||||||
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
||||||
});
|
});
|
||||||
@@ -39,6 +53,16 @@ describe("runtime frontend configuration", () => {
|
|||||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
||||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||||
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
|
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a Keycloak URL that does not identify a Realm", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,13 +22,53 @@ const browserHttpUrl = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtimeConfigSchema = z.object({
|
const keycloakIssuer = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.default("")
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (!value) return;
|
||||||
|
const parsed = browserHttpUrl.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transform((value) => value.replace(/\/+$/, ""));
|
||||||
|
|
||||||
|
const runtimeConfigSchema = z
|
||||||
|
.object({
|
||||||
|
TJWATER_AUTH_MODE: z.enum(["required", "disabled"]).default("required"),
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
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_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||||
|
})
|
||||||
|
.superRefine((config, context) => {
|
||||||
|
if (config.TJWATER_AUTH_MODE !== "required") return;
|
||||||
|
if (!config.TJWATER_KEYCLOAK_ISSUER) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["TJWATER_KEYCLOAK_ISSUER"],
|
||||||
|
message: "启用认证时必须配置 Keycloak issuer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!config.TJWATER_KEYCLOAK_CLIENT_ID) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["TJWATER_KEYCLOAK_CLIENT_ID"],
|
||||||
|
message: "启用认证时必须配置 Keycloak client ID"
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -899,6 +899,15 @@ button.agent-history-session-trigger:not(:disabled):active {
|
|||||||
background: var(--surface-reading);
|
background: var(--surface-reading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-agent-dock .agent-panel-shell {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f7f9fb;
|
||||||
|
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-ready-orbit,
|
.agent-ready-orbit,
|
||||||
.agent-ready-core {
|
.agent-ready-core {
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
import { expect, test, type Locator } from "@playwright/test";
|
import { expect, test, type Locator } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockAgentApi(page, {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
session_id: "browser-history-session",
|
||||||
|
title: "城南供水压力复核",
|
||||||
|
created_at: "2026-07-21T09:30:00+08:00",
|
||||||
|
updated_at: "2026-07-21T10:35:00+08:00",
|
||||||
|
run_status: "completed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
type BoundingBox = {
|
type BoundingBox = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -12,41 +27,36 @@ async function waitForAnimations(locator: Locator) {
|
|||||||
const animations = element
|
const animations = element
|
||||||
.getAnimations({ subtree: true })
|
.getAnimations({ subtree: true })
|
||||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
|
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
|
||||||
await Promise.all(
|
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
|
||||||
animations.map((animation) => animation.finished.catch(() => undefined))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
|
test("desktop Agent lives in the solid Main Frame navigation pane", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const topBar = page.locator("header.acrylic-navigation");
|
const topBar = page.locator("header.acrylic-navigation");
|
||||||
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
const agentConversation = agentPanel.getByRole("log");
|
||||||
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
||||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
const agentControls = agentPanel.locator(".agent-panel-band").last();
|
||||||
|
|
||||||
await expect(topBar).toBeVisible();
|
await expect(topBar).toBeVisible();
|
||||||
|
await expect(navigation).toBeVisible();
|
||||||
await expect(agentPanel).toBeVisible();
|
await expect(agentPanel).toBeVisible();
|
||||||
await expect(agentHeader).toBeVisible();
|
await expect(agentHeader).toBeVisible();
|
||||||
await expect(agentComposer).toBeVisible();
|
await expect(agentControls).toBeVisible();
|
||||||
await waitForAnimations(agentPanel);
|
await waitForAnimations(agentPanel);
|
||||||
|
|
||||||
await expectAcrylicSurface(topBar);
|
await expectAcrylicSurface(topBar);
|
||||||
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
|
await expect(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)");
|
||||||
await expectIntegratedHeader(agentHeader);
|
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||||
await expectContextualAcrylicSurface(agentComposer, {
|
await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)");
|
||||||
backdropFilter: "blur(",
|
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
|
||||||
maximumAlpha: 0.9,
|
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||||
minimumAlpha: 0.84
|
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||||
});
|
await expect(agentControls).toHaveCSS("border-top-width", "1px");
|
||||||
await expectLightweightMaterialContext(agentConversation);
|
|
||||||
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
|
|
||||||
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
|
|
||||||
await expect(
|
await expect(
|
||||||
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||||
).toHaveCSS("color", "rgb(51, 65, 85)");
|
).toHaveCSS("color", "rgb(51, 65, 85)");
|
||||||
@@ -56,68 +66,30 @@ test("desktop Agent layers floating controls inside one acrylic panel", async ({
|
|||||||
const panelBox = await agentPanel.boundingBox();
|
const panelBox = await agentPanel.boundingBox();
|
||||||
const conversationBox = await agentConversation.boundingBox();
|
const conversationBox = await agentConversation.boundingBox();
|
||||||
const scrollBox = await agentConversationScroll.boundingBox();
|
const scrollBox = await agentConversationScroll.boundingBox();
|
||||||
const composerBox = await agentComposer.boundingBox();
|
|
||||||
expect(panelBox).not.toBeNull();
|
expect(panelBox).not.toBeNull();
|
||||||
expect(conversationBox).not.toBeNull();
|
expect(conversationBox).not.toBeNull();
|
||||||
expect(scrollBox).not.toBeNull();
|
expect(scrollBox).not.toBeNull();
|
||||||
expect(composerBox).not.toBeNull();
|
expect(panelBox!.x).toBe(48);
|
||||||
|
expect(panelBox!.width).toBe(319);
|
||||||
expect(
|
expect(
|
||||||
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
|
Math.abs(conversationBox!.x - panelBox!.x)
|
||||||
).toBeLessThanOrEqual(1);
|
|
||||||
expect(
|
|
||||||
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
|
|
||||||
).toBeLessThanOrEqual(1);
|
|
||||||
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
|
|
||||||
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
|
|
||||||
expect(
|
|
||||||
Math.abs(
|
|
||||||
composerBox!.x -
|
|
||||||
panelBox!.x -
|
|
||||||
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
|
|
||||||
)
|
|
||||||
).toBeLessThanOrEqual(1);
|
).toBeLessThanOrEqual(1);
|
||||||
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
||||||
|
|
||||||
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
|
const beforeFocus = await getSurfacePresentation(agentPanel);
|
||||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
|
.poll(() => getSurfacePresentation(agentPanel))
|
||||||
.toEqual(beforeFocus);
|
.toEqual(beforeFocus);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectIntegratedHeader(header: Locator) {
|
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
|
||||||
const composition = await header.evaluate((element) => {
|
|
||||||
const style = getComputedStyle(element);
|
|
||||||
|
|
||||||
return {
|
|
||||||
backdropFilter: style.backdropFilter,
|
|
||||||
backgroundColor: style.backgroundColor,
|
|
||||||
borderTopWidth: style.borderTopWidth,
|
|
||||||
borderRadius: style.borderRadius
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(composition.backdropFilter).toBe("none");
|
|
||||||
expect(getAlpha(composition.backgroundColor)).toBe(0);
|
|
||||||
expect(composition.borderTopWidth).toBe("0px");
|
|
||||||
expect(composition.borderRadius).toBe("0px");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("desktop Agent header expands into one attached acrylic history surface", async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
|
||||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
|
||||||
const historyButton = agentPanel.getByRole("button", {
|
const historyButton = agentPanel.getByRole("button", {
|
||||||
name: "打开 Agent 历史记录"
|
name: "打开 Agent 历史记录"
|
||||||
});
|
});
|
||||||
const headerBefore = await agentHeader.boundingBox();
|
|
||||||
const conversationBefore = await agentConversation.boundingBox();
|
|
||||||
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
|
|
||||||
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
|
|
||||||
|
|
||||||
await historyButton.click();
|
await historyButton.click();
|
||||||
|
|
||||||
@@ -125,51 +97,7 @@ test("desktop Agent header expands into one attached acrylic history surface", a
|
|||||||
await expect(history).toBeVisible();
|
await expect(history).toBeVisible();
|
||||||
await waitForAnimations(history);
|
await waitForAnimations(history);
|
||||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||||
await page.mouse.move(720, 320);
|
|
||||||
await expectAttachedHistoryExtension(history);
|
|
||||||
await expectTransparentExpandedHeader(agentHeader, agentPanel);
|
|
||||||
await expectUnifiedHistoryStructure(history);
|
await expectUnifiedHistoryStructure(history);
|
||||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
|
||||||
await expect
|
|
||||||
.poll(() => getSurfacePresentation(historyButton))
|
|
||||||
.toEqual(historyButtonPresentationBefore);
|
|
||||||
|
|
||||||
const sessionTrigger = history.locator(".agent-history-session-trigger").first();
|
|
||||||
const sessionTriggerBox = await sessionTrigger.boundingBox();
|
|
||||||
const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox();
|
|
||||||
expect(sessionTriggerBox).not.toBeNull();
|
|
||||||
expect(historyHeadingBox).not.toBeNull();
|
|
||||||
await page.mouse.move(
|
|
||||||
sessionTriggerBox!.x + sessionTriggerBox!.width / 2,
|
|
||||||
sessionTriggerBox!.y + sessionTriggerBox!.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.down();
|
|
||||||
await expect(sessionTrigger).toHaveCSS("outline-style", "none");
|
|
||||||
await page.mouse.move(
|
|
||||||
historyHeadingBox!.x + historyHeadingBox!.width / 2,
|
|
||||||
historyHeadingBox!.y + historyHeadingBox!.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const headerBox = await agentHeader.boundingBox();
|
|
||||||
const agentPanelBox = await agentPanel.boundingBox();
|
|
||||||
const historyBox = await history.boundingBox();
|
|
||||||
const conversationAfter = await agentConversation.boundingBox();
|
|
||||||
expect(headerBefore).not.toBeNull();
|
|
||||||
expect(headerBox).not.toBeNull();
|
|
||||||
expect(agentPanelBox).not.toBeNull();
|
|
||||||
expect(historyBox).not.toBeNull();
|
|
||||||
expect(conversationBefore).not.toBeNull();
|
|
||||||
expect(conversationAfter).not.toBeNull();
|
|
||||||
expectRoundedValue(headerBox!.y, headerBefore!.y);
|
|
||||||
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
|
|
||||||
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
|
|
||||||
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
|
|
||||||
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
|
|
||||||
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
|
|
||||||
expectRoundedValue(historyBox!.x, headerBox!.x);
|
|
||||||
expectRoundedValue(historyBox!.width, headerBox!.width);
|
|
||||||
expectRoundedBox(conversationAfter!, conversationBefore!);
|
|
||||||
|
|
||||||
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
||||||
const renameInput = history.locator("input").first();
|
const renameInput = history.locator("input").first();
|
||||||
@@ -186,24 +114,14 @@ test("desktop Agent header expands into one attached acrylic history surface", a
|
|||||||
await expect(history).toBeHidden();
|
await expect(history).toBeHidden();
|
||||||
await expect(historyButton).toBeFocused();
|
await expect(historyButton).toBeFocused();
|
||||||
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
|
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
|
||||||
|
|
||||||
await historyButton.click();
|
|
||||||
await expect(history).toBeVisible();
|
|
||||||
await page.mouse.click(720, 320);
|
|
||||||
await expect(history).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile Agent mirrors the desktop floating history surface", async ({
|
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||||
|
|
||||||
const agentPanel = page
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||||
.locator('aside[aria-label="Agent 命令面板"]')
|
|
||||||
.filter({ visible: true });
|
|
||||||
await expect(agentPanel).toBeVisible();
|
await expect(agentPanel).toBeVisible();
|
||||||
await waitForAnimations(agentPanel);
|
await waitForAnimations(agentPanel);
|
||||||
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||||
@@ -261,15 +179,17 @@ test("mobile Agent mirrors the desktop floating history surface", async ({
|
|||||||
await expect(historyButton).toBeFocused();
|
await expect(historyButton).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
|
test("Main Frame navigation respects forced color mode", async ({ page }) => {
|
||||||
await page.emulateMedia({ forcedColors: "active" });
|
await page.emulateMedia({ forcedColors: "active" });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const composer = page.locator(".agent-panel-composer");
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
await expect(composer).toBeVisible();
|
const workspace = page.locator("#main-workspace");
|
||||||
await expect(composer).toHaveCSS("backdrop-filter", "none");
|
await expect(navigation).toBeVisible();
|
||||||
await expect(composer).toHaveCSS("background-image", "none");
|
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||||
await expect(composer).toHaveCSS("box-shadow", "none");
|
await expect(navigation).toHaveCSS("box-shadow", "none");
|
||||||
|
await expect(workspace).toBeVisible();
|
||||||
|
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
|
test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
const workspace = page.locator("#main-workspace");
|
||||||
await expect(panel).toBeVisible();
|
const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
|
||||||
await expect(resizeHandle).toBeVisible();
|
expect(expandedWidth).toBeGreaterThan(400);
|
||||||
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||||
const handleBox = await resizeHandle.boundingBox();
|
|
||||||
expect(handleBox).not.toBeNull();
|
|
||||||
|
|
||||||
await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
|
||||||
|
|
||||||
await resizeHandle.focus();
|
|
||||||
await page.keyboard.press("Home");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
|
|
||||||
await page.keyboard.press("ArrowRight");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
|
|
||||||
await page.keyboard.press("End");
|
|
||||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||||
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||||
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
|
||||||
|
|
||||||
const rail = page.locator('aside[aria-label="Agent 折叠栏"]');
|
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||||
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ });
|
await expect(agentPanel).toBeVisible();
|
||||||
await expect(rail).toBeVisible();
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||||
await expect(rail).toHaveCSS("width", "72px");
|
});
|
||||||
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
|
|
||||||
|
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
await expandButton.click();
|
|
||||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
|
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
||||||
|
await prompt.fill("保留尚未发送的诊断问题");
|
||||||
|
const conditionToggle = page
|
||||||
|
.locator("header.acrylic-navigation")
|
||||||
|
.getByRole("button", { name: "工况任务", exact: true });
|
||||||
|
await conditionToggle.click();
|
||||||
|
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
|
await expect(conditionPanel).toBeVisible();
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
|
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||||
|
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
|
||||||
|
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
|
||||||
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
|
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
|
||||||
|
await conditionToggle.click();
|
||||||
|
await expect(conditionPanel).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
|
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
|
||||||
@@ -71,19 +71,18 @@ test.describe("mobile touch layout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Temporarily disabled: this regression flow would start a real Agent run.
|
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||||
// Agent analysis must be explicitly triggered by a user, not by automated tests.
|
await mockAgentApi(page);
|
||||||
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
|
||||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
||||||
// Agent execution must remain an explicit user action.
|
await page.getByRole("button", { name: "工况汇总" }).click();
|
||||||
// await page.getByRole("button", { name: "工况汇总" }).click();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||||
|
await expect(page.getByText("已发起工况汇总", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ test("submits a custom answer with the actionable id after a later tool placehol
|
|||||||
await mockQuestionApi(page, replies);
|
await mockQuestionApi(page, replies);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
|
const prompt = page
|
||||||
|
.getByPlaceholder(/输入调度问题/)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
await prompt.fill("测试 question 自定义回答");
|
await prompt.fill("测试 question 自定义回答");
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "发送 Agent 指令" })
|
.getByRole("button", { name: "发送 Agent 指令" })
|
||||||
@@ -20,7 +23,9 @@ test("submits a custom answer with the actionable id after a later tool placehol
|
|||||||
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
|
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
|
||||||
await panel.getByRole("button", { name: "提交回答" }).click();
|
await panel.getByRole("button", { name: "提交回答" }).click();
|
||||||
|
|
||||||
await expect.poll(() => replies).toEqual([
|
await expect
|
||||||
|
.poll(() => replies)
|
||||||
|
.toEqual([
|
||||||
{
|
{
|
||||||
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
||||||
body: {
|
body: {
|
||||||
@@ -54,10 +59,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
|
|||||||
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
|
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function mockQuestionApi(
|
async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) {
|
||||||
page: Page,
|
|
||||||
replies: Array<{ pathname: string; body: unknown }>
|
|
||||||
) {
|
|
||||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const pathname = new URL(request.url()).pathname;
|
const pathname = new URL(request.url()).pathname;
|
||||||
@@ -119,8 +121,8 @@ function createQuestionStream() {
|
|||||||
question: "你想分析哪一部分?",
|
question: "你想分析哪一部分?",
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: "雨污混接分析",
|
label: "供水分区压力分析",
|
||||||
description: "检查混接问题"
|
description: "检查分区压力问题"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ test.beforeAll(async () => {
|
|||||||
phase: "planning",
|
phase: "planning",
|
||||||
status: "running",
|
status: "running",
|
||||||
title: "正在分析运行上下文",
|
title: "正在分析运行上下文",
|
||||||
detail: "核对泵站、液位与降雨过程。",
|
detail: "核对泵站、液位与用水过程。",
|
||||||
started_at: Date.now()
|
started_at: Date.now()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -47,8 +47,8 @@ test.beforeAll(async () => {
|
|||||||
id: "inspect-rainfall",
|
id: "inspect-rainfall",
|
||||||
phase: "evidence",
|
phase: "evidence",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
title: "正在核对降雨过程与汇水区响应关系",
|
title: "正在核对用水过程与供水分区响应关系",
|
||||||
detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。",
|
detail: "读取最近十二小时分钟级流量序列,并检查每个供水分区的响应延迟是否处于合理范围。",
|
||||||
started_at: Date.now()
|
started_at: Date.now()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -74,7 +74,7 @@ test.beforeAll(async () => {
|
|||||||
todos: [
|
todos: [
|
||||||
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
|
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
|
||||||
{ id: "inspect-level", content: "核对液位计连续性", status: "pending" },
|
{ id: "inspect-level", content: "核对液位计连续性", status: "pending" },
|
||||||
{ id: "assess-overflow", content: "判断溢流风险", status: "pending" }
|
{ id: "assess-supply-risk", content: "判断供水风险", status: "pending" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -110,7 +110,7 @@ test.beforeAll(async () => {
|
|||||||
id: "assess-risk",
|
id: "assess-risk",
|
||||||
phase: "analysis",
|
phase: "analysis",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
title: "溢流风险判断完成",
|
title: "供水风险判断完成",
|
||||||
detail: "已完成风险点位排序。",
|
detail: "已完成风险点位排序。",
|
||||||
ended_at: Date.now()
|
ended_at: Date.now()
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ test.beforeAll(async () => {
|
|||||||
id: "assess-risk",
|
id: "assess-risk",
|
||||||
phase: "analysis",
|
phase: "analysis",
|
||||||
status: "running",
|
status: "running",
|
||||||
title: "正在判断溢流风险",
|
title: "正在判断供水风险",
|
||||||
detail: "结合流量突变与液位趋势形成判断。",
|
detail: "结合流量突变与液位趋势形成判断。",
|
||||||
started_at: Date.now()
|
started_at: Date.now()
|
||||||
}
|
}
|
||||||
@@ -382,7 +382,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
requestAnimationFrame(sample);
|
requestAnimationFrame(sample);
|
||||||
});
|
});
|
||||||
triggerProgressAppend();
|
triggerProgressAppend();
|
||||||
await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible();
|
await expect(liveProgress.getByText("正在判断供水风险", { exact: true })).toBeVisible();
|
||||||
await expect(liveProgress.locator("li")).toHaveCount(3);
|
await expect(liveProgress.locator("li")).toHaveCount(3);
|
||||||
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
|
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
|
||||||
await expect
|
await expect
|
||||||
@@ -450,7 +450,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
);
|
);
|
||||||
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
|
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true })
|
expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
|
||||||
).toHaveCSS("white-space", "normal");
|
).toHaveCSS("white-space", "normal");
|
||||||
await progressToggle.click();
|
await progressToggle.click();
|
||||||
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
|
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
|
||||||
@@ -468,7 +468,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
|
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
|
||||||
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
|
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })
|
page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () =>
|
.poll(async () =>
|
||||||
@@ -541,7 +541,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()
|
page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", {
|
const expandedLongTitle = expandedProgress.getByText("正在核对用水过程与供水分区响应关系", {
|
||||||
exact: true
|
exact: true
|
||||||
});
|
});
|
||||||
await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
|
await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
|
||||||
@@ -562,7 +562,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
await expect(latestProgressRegion).toContainText("最近3/3");
|
await expect(latestProgressRegion).toContainText("最近3/3");
|
||||||
triggerProgressAppend();
|
triggerProgressAppend();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
|
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断供水风险", { exact: true })
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await latestProgressToggle.click();
|
await latestProgressToggle.click();
|
||||||
await expect(latestProgressRegion).toContainText("全部4/4");
|
await expect(latestProgressRegion).toContainText("全部4/4");
|
||||||
@@ -570,7 +570,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
|
|
||||||
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
|
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
|
||||||
await expect(latestExpandedProgress).toBeVisible();
|
await expect(latestExpandedProgress).toBeVisible();
|
||||||
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible();
|
await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
const conversationScroll = page.locator(".agent-conversation-scroll");
|
const conversationScroll = page.locator(".agent-conversation-scroll");
|
||||||
await conversationScroll.hover();
|
await conversationScroll.hover();
|
||||||
@@ -673,7 +673,7 @@ function createLongMarkdownResponse() {
|
|||||||
"当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
|
"当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
|
||||||
content += "- 检查泵站运行状态与实时流量。\n";
|
content += "- 检查泵站运行状态与实时流量。\n";
|
||||||
content += "- 核对液位计连续性和异常峰值。\n";
|
content += "- 核对液位计连续性和异常峰值。\n";
|
||||||
content += "- 结合降雨过程判断溢流风险。\n\n";
|
content += "- 结合用水过程判断供水风险。\n\n";
|
||||||
}
|
}
|
||||||
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
|
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { mockScadaApi } from "./support/mock-scada-api";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await prepareWorkbench(page);
|
await prepareWorkbench(page);
|
||||||
@@ -113,6 +114,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
|
|||||||
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
|
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
|
||||||
|
await expect(timelineSummary).toBeVisible();
|
||||||
|
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
|
||||||
|
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
|
||||||
|
|
||||||
|
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
|
||||||
|
await expect(timelineSheet).toBeVisible();
|
||||||
|
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
|
||||||
|
await expect(timelineSheet).toBeHidden();
|
||||||
|
await expect(timelineSummary).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
|
||||||
|
|
||||||
|
const toolPanel = page.getByRole("region", { name: "更多" });
|
||||||
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
||||||
|
await expect(toolPanel).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [panelBox, dockBox] = await Promise.all([
|
||||||
|
toolPanel.boundingBox(),
|
||||||
|
mapDock.boundingBox()
|
||||||
|
]);
|
||||||
|
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
|
||||||
|
return Math.round(panelBox.y + panelBox.height - dockBox.y);
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(-8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const mapZoom = page.getByRole("group", { name: "地图缩放" });
|
||||||
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
||||||
|
await expect(mapZoom).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [zoomBox, dockBox] = await Promise.all([
|
||||||
|
mapZoom.boundingBox(),
|
||||||
|
mapDock.boundingBox()
|
||||||
|
]);
|
||||||
|
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
|
||||||
|
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(-8);
|
||||||
|
|
||||||
|
const zoomBox = await mapZoom.boundingBox();
|
||||||
|
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
|
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
@@ -151,9 +211,11 @@ async function prepareWorkbench(page: Page) {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
contentType: "application/javascript",
|
contentType: "application/javascript",
|
||||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
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_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -169,4 +231,5 @@ async function prepareWorkbench(page: Page) {
|
|||||||
await page.route("**/*.riv", async (route) => {
|
await page.route("**/*.riv", async (route) => {
|
||||||
await route.abort();
|
await route.abort();
|
||||||
});
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { expect, test, type Page } 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 mockRuntimeAndMap(page, { valves: 0, reservoirs: 0, pumps: 0, tanks: 0 });
|
||||||
|
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" });
|
||||||
|
|
||||||
|
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
|
||||||
|
await expect(layerTool).toBeEnabled();
|
||||||
|
await layerTool.click();
|
||||||
|
await expect(page.getByRole("button", { name: /^管线/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^节点/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^阀门/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /^水库/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /^水泵/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /^水箱/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows data-dependent layer controls only when GeoServer reports features", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await mockRuntimeAndMap(page, { valves: 1, reservoirs: 1, pumps: 2, tanks: 1 });
|
||||||
|
await mockScadaApi(page);
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
|
||||||
|
await expect(layerTool).toBeEnabled();
|
||||||
|
await layerTool.click();
|
||||||
|
await expect(page.getByRole("button", { name: /^阀门/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^水库/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^水泵/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
|
valve: Boolean(globalThis.__waterNetworkMap?.getLayer("valves-symbol")),
|
||||||
|
valveLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-valves")),
|
||||||
|
reservoir: Boolean(globalThis.__waterNetworkMap?.getLayer("reservoirs-symbol")),
|
||||||
|
reservoirLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-reservoirs")),
|
||||||
|
pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")),
|
||||||
|
tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol"))
|
||||||
|
}))).toEqual({
|
||||||
|
valve: true,
|
||||||
|
valveLabel: true,
|
||||||
|
reservoir: true,
|
||||||
|
reservoirLabel: true,
|
||||||
|
pump: true,
|
||||||
|
tank: true
|
||||||
|
});
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
|
||||||
|
return {
|
||||||
|
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
|
||||||
|
simulationLabel: layerIds.indexOf("simulation-burst-label")
|
||||||
|
};
|
||||||
|
})).toMatchObject({
|
||||||
|
valveLabel: expect.any(Number),
|
||||||
|
simulationLabel: expect.any(Number)
|
||||||
|
});
|
||||||
|
const layerOrder = await page.evaluate(() => {
|
||||||
|
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
|
||||||
|
return [
|
||||||
|
layerIds.indexOf("workbench-value-label-valves"),
|
||||||
|
layerIds.indexOf("simulation-burst-label")
|
||||||
|
];
|
||||||
|
});
|
||||||
|
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads required map layers while optional layer probes are still pending", async ({ page }) => {
|
||||||
|
let releaseProbe = () => {};
|
||||||
|
const probeGate = new Promise<void>((resolve) => {
|
||||||
|
releaseProbe = resolve;
|
||||||
|
});
|
||||||
|
await mockRuntimeAndMap(
|
||||||
|
page,
|
||||||
|
{ valves: 0, reservoirs: 0, pumps: 1, tanks: 0 },
|
||||||
|
{ probeGate }
|
||||||
|
);
|
||||||
|
await mockScadaApi(page);
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
|
pipes: Boolean(globalThis.__waterNetworkMap?.getLayer("pipes-casing")),
|
||||||
|
pumps: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
|
||||||
|
}))).toEqual({ pipes: true, pumps: false });
|
||||||
|
} finally {
|
||||||
|
releaseProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect.poll(() => page.evaluate(() =>
|
||||||
|
Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mockRuntimeAndMap(
|
||||||
|
page: Page,
|
||||||
|
featureCounts: Record<"valves" | "reservoirs" | "pumps" | "tanks", number>,
|
||||||
|
options: { probeGate?: Promise<void> } = {}
|
||||||
|
) {
|
||||||
|
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 page.route("https://scada-map.invalid/geoserver/tjwater_next/ows?**", async (route) => {
|
||||||
|
await options.probeGate;
|
||||||
|
const typeNames = new URL(route.request().url()).searchParams.get("typeNames");
|
||||||
|
const sourceId = typeNames?.split(":").at(-1);
|
||||||
|
const featureCount = sourceId && sourceId in featureCounts
|
||||||
|
? featureCounts[sourceId as keyof typeof featureCounts]
|
||||||
|
: 0;
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/xml",
|
||||||
|
body: `<wfs:FeatureCollection numberMatched="${featureCount}" numberReturned="0" />`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
|
|||||||
|
|
||||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
@@ -111,6 +112,7 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
|
|||||||
|
|
||||||
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
|
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
|||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
@@ -199,6 +202,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
|||||||
|
|
||||||
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
|
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
|
|||||||
|
|
||||||
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
|
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
|
||||||
const reference = page
|
const reference = page
|
||||||
.getByRole("region", { name: "工况任务", exact: true })
|
.getByRole("region", { name: "工况任务", exact: true })
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
type MockAgentApiOptions = {
|
||||||
|
model?: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: "bolt" | "sparkle";
|
||||||
|
};
|
||||||
|
sessions?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
|
||||||
|
const model = options.model ?? {
|
||||||
|
id: "test/model",
|
||||||
|
label: "测试模型",
|
||||||
|
description: "Playwright 浏览器测试",
|
||||||
|
icon: "bolt" as const
|
||||||
|
};
|
||||||
|
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
|
||||||
|
if (pathname.endsWith("/stream")) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||||
|
},
|
||||||
|
body: [
|
||||||
|
{ type: "start", messageId: "assistant-browser-test" },
|
||||||
|
{ type: "text-start", id: "answer" },
|
||||||
|
{ type: "text-delta", id: "answer", delta: "已收到工况汇总请求。" },
|
||||||
|
{ type: "text-end", id: "answer" },
|
||||||
|
{ type: "finish", finishReason: "stop" }
|
||||||
|
]
|
||||||
|
.map((part) => `data: ${JSON.stringify(part)}\n\n`)
|
||||||
|
.join("")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.endsWith("/sessions")) {
|
||||||
|
await route.fulfill({ json: { sessions: options.sessions ?? [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.endsWith("/models")) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
default_model: model.id,
|
||||||
|
models: [model]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.endsWith("/ui-registry")) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: [],
|
||||||
|
components: [],
|
||||||
|
actions: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.endsWith("/frontend-action-registry")) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
schema_version: "frontend-action-registry@1",
|
||||||
|
actions: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({ json: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: "{}" });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,33 +2,31 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
|
test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.waitForFunction(() =>
|
await page.waitForFunction(
|
||||||
Boolean(window.__waterNetworkMap?.getLayer("scada-hit"))
|
() =>
|
||||||
|
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) &&
|
||||||
|
!window.__waterNetworkMap?.isMoving()
|
||||||
);
|
);
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const map = window.__waterNetworkMap;
|
const map = window.__waterNetworkMap;
|
||||||
document.documentElement.dataset.panelCameraMoveCount = "0";
|
document.documentElement.dataset.panelCameraMoveCount = "0";
|
||||||
map?.on("movestart", () => {
|
map?.on("movestart", () => {
|
||||||
const currentCount = Number(
|
const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0");
|
||||||
document.documentElement.dataset.panelCameraMoveCount ?? "0"
|
|
||||||
);
|
|
||||||
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
|
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
|
||||||
await page.waitForTimeout(250);
|
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
|
||||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
await page.waitForTimeout(250);
|
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||||
await page.waitForTimeout(250);
|
await expect(conditionPanel).toHaveCSS("width", "880px");
|
||||||
|
|
||||||
expect(
|
expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe(
|
||||||
await page.evaluate(
|
"0"
|
||||||
() => document.documentElement.dataset.panelCameraMoveCount
|
);
|
||||||
)
|
|
||||||
).toBe("0");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
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(
|
const EMPTY_RASTER_TILE = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
"base64"
|
"base64"
|
||||||
);
|
);
|
||||||
|
|
||||||
test.describe("neutral blue mist workbench", () => {
|
test.describe("Main Frame workbench", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await prepareDeterministicWorkbench(page);
|
await prepareDeterministicWorkbench(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop light basemap keeps both floating panels legible", async ({ page }) => {
|
test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
|
|
||||||
await expectDesktopFloatingGeometry(page);
|
await expectDesktopMainFrameGeometry(page);
|
||||||
await expectAcrylicAlphas(page, {
|
await expectSolidMainFrameMaterials(page);
|
||||||
navigation: 0.64,
|
|
||||||
agentPanel: 0.82,
|
|
||||||
conditionPanel: 0.56,
|
|
||||||
control: 0.74
|
|
||||||
});
|
|
||||||
await expectLoadedChineseFont(page);
|
await expectLoadedChineseFont(page);
|
||||||
await expectBodyContrast(page);
|
await expectBodyContrast(page);
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
||||||
@@ -31,42 +28,35 @@ test.describe("neutral blue mist workbench", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => {
|
test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
await selectBasemap(page, "影像");
|
await selectBasemap(page, "影像");
|
||||||
|
|
||||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||||
await expectDesktopFloatingGeometry(page);
|
await expectDesktopMainFrameGeometry(page);
|
||||||
await expectAcrylicAlphas(page, {
|
await expectSolidMainFrameMaterials(page);
|
||||||
navigation: 0.8,
|
|
||||||
agentPanel: 0.88,
|
|
||||||
conditionPanel: 0.72,
|
|
||||||
control: 0.86
|
|
||||||
});
|
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
maxDiffPixelRatio: 0.01
|
maxDiffPixelRatio: 0.01
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => {
|
test("wide desktop expands the map document when navigation is collapsed", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
await openWorkbench(page);
|
await openWorkbench(page);
|
||||||
|
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
const workspace = page.locator("#main-workspace");
|
||||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
const expandedWorkspace = await workspace.boundingBox();
|
||||||
|
expect(expandedWorkspace).not.toBeNull();
|
||||||
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
|
|
||||||
await resizeHandle.focus();
|
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
||||||
await page.keyboard.press("End");
|
await expect(navigation).toHaveCSS("width", "48px");
|
||||||
await expect(agentPanel).toHaveCSS("width", "720px");
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
|
||||||
await expectMapCorridor(agentPanel, conditionPanel, 680);
|
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872);
|
||||||
|
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
|
||||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
|
||||||
await expect(conditionPanel).toHaveCSS("width", "960px");
|
|
||||||
await expect(agentPanel).toHaveCSS("width", "628px");
|
|
||||||
await expectMapCorridor(agentPanel, conditionPanel, 256);
|
|
||||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
maxDiffPixelRatio: 0.01
|
maxDiffPixelRatio: 0.01
|
||||||
@@ -116,13 +106,23 @@ test.describe("neutral blue mist workbench", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function prepareDeterministicWorkbench(page: Page) {
|
async function prepareDeterministicWorkbench(page: Page) {
|
||||||
|
await mockAgentApi(page, {
|
||||||
|
model: {
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速响应",
|
||||||
|
icon: "bolt"
|
||||||
|
}
|
||||||
|
});
|
||||||
await page.route("**/runtime-config.js", async (route) => {
|
await page.route("**/runtime-config.js", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
contentType: "application/javascript",
|
contentType: "application/javascript",
|
||||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
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_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -144,11 +144,14 @@ async function prepareDeterministicWorkbench(page: Page) {
|
|||||||
await page.route("**/*.riv", async (route) => {
|
await page.route("**/*.riv", async (route) => {
|
||||||
await route.abort();
|
await route.abort();
|
||||||
});
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkbench(page: Page) {
|
async function openWorkbench(page: Page) {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.locator("main").evaluate((element) => {
|
const workbench = page.locator("main[data-basemap-tone]");
|
||||||
|
await workbench.waitFor();
|
||||||
|
await workbench.evaluate((element) => {
|
||||||
element.setAttribute("data-visual-test", "true");
|
element.setAttribute("data-visual-test", "true");
|
||||||
});
|
});
|
||||||
await page.addStyleTag({
|
await page.addStyleTag({
|
||||||
@@ -176,51 +179,67 @@ async function openWorkbench(page: Page) {
|
|||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(350);
|
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit")));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
||||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||||
await page.getByRole("button", { name: label, exact: true }).click();
|
await page.getByRole("button", { name: label, exact: true }).click();
|
||||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||||
await page.waitForTimeout(220);
|
await expect(page.locator("main")).toHaveAttribute(
|
||||||
|
"data-basemap-tone",
|
||||||
|
label === "影像" ? "satellite" : "light"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectDesktopFloatingGeometry(page: Page) {
|
async function expectDesktopMainFrameGeometry(page: Page) {
|
||||||
|
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||||
|
const workspace = page.locator("#main-workspace");
|
||||||
|
const mapWindow = page.locator("#workspace-map");
|
||||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
await expect(navigation).toHaveCSS("width", "368px");
|
||||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
await expect(mapWindow).toHaveCSS("border-radius", "0px");
|
||||||
|
|
||||||
await expect(agentPanel).toHaveCSS("width", "500px");
|
|
||||||
await expect(conditionPanel).toHaveCSS("width", "432px");
|
|
||||||
await expect(agentPanel).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentHeader).toHaveCSS("border-radius", "0px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-radius", "16px");
|
|
||||||
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
|
|
||||||
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
|
|
||||||
|
|
||||||
|
const navigationBox = await navigation.boundingBox();
|
||||||
|
const workspaceBox = await workspace.boundingBox();
|
||||||
|
const mapBox = await mapWindow.boundingBox();
|
||||||
const agentBox = await agentPanel.boundingBox();
|
const agentBox = await agentPanel.boundingBox();
|
||||||
const conditionBox = await conditionPanel.boundingBox();
|
expect(navigationBox).not.toBeNull();
|
||||||
|
expect(workspaceBox).not.toBeNull();
|
||||||
|
expect(mapBox).not.toBeNull();
|
||||||
expect(agentBox).not.toBeNull();
|
expect(agentBox).not.toBeNull();
|
||||||
expect(conditionBox).not.toBeNull();
|
expect(navigationBox!.x).toBe(0);
|
||||||
expect(agentBox!.x).toBe(12);
|
expect(navigationBox!.y).toBe(56);
|
||||||
expect(agentBox!.y).toBe(96);
|
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
|
||||||
expect(agentBox!.y + agentBox!.height).toBe(884);
|
expect(workspaceBox!.y).toBe(navigationBox!.y);
|
||||||
expect(conditionBox!.x).toBe(944);
|
expect(mapBox).toEqual(workspaceBox);
|
||||||
expect(conditionBox!.y).toBe(96);
|
expect(agentBox!.x).toBeGreaterThanOrEqual(48);
|
||||||
|
expect(agentBox!.x).toBeLessThanOrEqual(49);
|
||||||
|
expect(
|
||||||
|
Math.abs(agentBox!.x + agentBox!.width - (navigationBox!.x + navigationBox!.width))
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectMapCorridor(
|
async function expectSolidMainFrameMaterials(page: Page) {
|
||||||
agentPanel: Locator,
|
const materials = await page.evaluate(() => {
|
||||||
conditionPanel: Locator,
|
const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!);
|
||||||
minimumWidth: number
|
const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
|
||||||
) {
|
return {
|
||||||
const agentBox = await agentPanel.boundingBox();
|
navigationBackdrop: navigation.backdropFilter,
|
||||||
const conditionBox = await conditionPanel.boundingBox();
|
navigationBackground: navigation.backgroundColor,
|
||||||
expect(agentBox).not.toBeNull();
|
agentBackdrop: agent.backdropFilter,
|
||||||
expect(conditionBox).not.toBeNull();
|
agentBackground: agent.backgroundColor,
|
||||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
|
agentShadow: agent.boxShadow
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(materials).toEqual({
|
||||||
|
navigationBackdrop: "none",
|
||||||
|
navigationBackground: "rgb(237, 241, 245)",
|
||||||
|
agentBackdrop: "none",
|
||||||
|
agentBackground: "rgb(247, 249, 251)",
|
||||||
|
agentShadow: "none"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectLoadedChineseFont(page: Page) {
|
async function expectLoadedChineseFont(page: Page) {
|
||||||
@@ -231,41 +250,6 @@ async function expectLoadedChineseFont(page: Page) {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectAcrylicAlphas(
|
|
||||||
page: Page,
|
|
||||||
expected: {
|
|
||||||
navigation: number;
|
|
||||||
agentPanel: number;
|
|
||||||
conditionPanel: number;
|
|
||||||
control: number;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const alphas = await page.evaluate(() => {
|
|
||||||
return {
|
|
||||||
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
|
|
||||||
agentPanel: alphaOf(
|
|
||||||
document.querySelector('aside[aria-label="Agent 命令面板"]')
|
|
||||||
),
|
|
||||||
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
|
|
||||||
control: alphaOf(document.querySelector(".acrylic-control"))
|
|
||||||
};
|
|
||||||
|
|
||||||
function alphaOf(element: Element | null) {
|
|
||||||
if (!element) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const color = getComputedStyle(element).backgroundColor;
|
|
||||||
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
|
|
||||||
return channels[3] ?? 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
|
|
||||||
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
|
|
||||||
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
|
|
||||||
expect(alphas.control).toBeCloseTo(expected.control, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectBodyContrast(page: Page) {
|
async function expectBodyContrast(page: Page) {
|
||||||
const contrast = await page
|
const contrast = await page
|
||||||
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 199 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 219 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 174 KiB |
@@ -0,0 +1,154 @@
|
|||||||
|
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 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const workspace = page.locator("#main-workspace");
|
||||||
|
const map = page.locator("#workspace-map");
|
||||||
|
|
||||||
|
await expect(workspace).toHaveAttribute("data-layout-mode", "default");
|
||||||
|
await expect(map).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||||
|
await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "任务视图" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a generated Artifact stays in peek until explicitly opened", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||||
|
const peek = page.getByRole("complementary", { name: "Agent 成果预览" });
|
||||||
|
|
||||||
|
await expect(peek).toBeVisible();
|
||||||
|
await expect(peek).toContainText("北辰分区压力异常诊断");
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||||
|
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toHaveCount(0);
|
||||||
|
|
||||||
|
await peek.getByRole("button", { name: "打开成果" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||||
|
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||||
|
await expect(page.locator("#artifact-workspace")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent expands over an active Artifact without resizing the main workspace", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await openFirstArtifact(page);
|
||||||
|
|
||||||
|
const workspace = page.locator("#main-workspace");
|
||||||
|
const before = await workspace.boundingBox();
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
|
||||||
|
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||||
|
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||||
|
const after = await workspace.boundingBox();
|
||||||
|
|
||||||
|
expect(after).not.toBeNull();
|
||||||
|
expect(after!.x).toBe(before!.x);
|
||||||
|
expect(after!.width).toBe(before!.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await openFirstArtifact(page);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "专注分析" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||||
|
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "显示地图" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
|
||||||
|
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "将成果收起为预览" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||||
|
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "销毁预览成果" }).click();
|
||||||
|
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toHaveCount(0);
|
||||||
|
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-spatial Artifacts open directly in focus mode", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||||
|
await page.getByRole("button", { name: "销毁预览成果" }).click();
|
||||||
|
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||||
|
await page.getByRole("button", { name: "打开成果" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||||
|
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析" })).toBeVisible();
|
||||||
|
await expect(page.locator("#workspace-map")).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "显示地图" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("narrow workspaces switch between Artifact and map as pages", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1180, height: 820 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await openFirstArtifact(page);
|
||||||
|
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||||
|
await page.getByRole("button", { name: "显示地图" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_only");
|
||||||
|
await expect(page.locator("#workspace-map")).toBeVisible();
|
||||||
|
await expect(page.locator("#artifact-workspace")).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "返回分析成果" }).click();
|
||||||
|
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
|
||||||
|
await expect(page.locator("#artifact-workspace")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the condition feed remains floating and starts closed", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openFirstArtifact(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
|
||||||
|
await page.getByRole("button", { name: "打开成果" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareMainFrame(page: Page) {
|
||||||
|
await mockAgentApi(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://workspace-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: "true",
|
||||||
|
TJWATER_ENABLE_MSW: "false"
|
||||||
|
};`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("https://workspace-map.invalid/**", async (route) => {
|
||||||
|
await route.fulfill({ status: 204 });
|
||||||
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
|
}
|
||||||
+5
-1
@@ -8,9 +8,13 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js";
|
|||||||
|
|
||||||
function renderRuntimeConfig(values: Record<string, string>) {
|
function renderRuntimeConfig(values: Record<string, string>) {
|
||||||
const config = {
|
const config = {
|
||||||
|
TJWATER_AUTH_MODE: values.TJWATER_AUTH_MODE || "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
||||||
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
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_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_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
|
||||||
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
|
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
|
||||||
|
|||||||
Reference in New Issue
Block a user