37 Commits
Author SHA1 Message Date
jiang f9c71cc076 feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s
2026-09-08 18:18:40 +08:00
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
jiang abef9ffabf fix: bypass blocked Keycloak session iframe
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m31s
2026-08-19 18:12:21 +08:00
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
jiang 21dbfd82b6 fix: decouple timeline from workspace layout
The regression recurred because a floating timeline height was still exported as shared layout space and its z-index exceeded artifacts and notices. Reserve zero layout height and define the timeline below artifact and notification overlays.
2026-08-19 16:58:55 +08:00
jiang 760370c93f fix: refine floating workspace controls
Keep ScaleInfo pinned to the map corner and move the centered timeline above collisions, preventing the previous conditional offset from lifting the scale. Remove collapsed Agent rail width reservation and add timeline state transitions.
2026-08-19 16:53:21 +08:00
jiang 3db95c27b5 fix: align scale info with floating timeline 2026-08-19 16:45:35 +08:00
jiang 45f1ebee7e feat: add operational timeline workspace 2026-08-19 16:42:41 +08:00
jiang d2f2895c36 feat: refactor artifact analysis workspace 2026-08-19 16:09:51 +08:00
jiang bfadc95f93 feat: add workspace task view 2026-08-19 14:34:03 +08:00
jiang be7b4149f2 feat: redesign floating workspace interactions 2026-08-19 14:12:08 +08:00
jiang 1068304da5 feat: add workspace layout and window swapping 2026-08-19 13:52:44 +08:00
jiang f58e0a70f4 feat: add adaptive agent workspace windows 2026-08-19 12:57:37 +08:00
jiang d08cf2abc1 feat: add Keycloak authentication 2026-08-19 12:11:18 +08:00
jiang bdd5eff776 refactor: simplify workbench layout and browser tests 2026-08-18 17:06:42 +08:00
jiang bdf4436899 fix: preserve backdrop filter in production
Generic Container CI/CD / test-build-publish (push) Successful in 1m48s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m18s
2026-08-17 20:22:35 +08:00
jiang d29792a5c5 fix: isolate Next frontend deployment
Generic Container CI/CD / test-build-publish (push) Successful in 25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 25s
2026-08-17 19:43:49 +08:00
jiang d1e69ad59b build: use mainland package mirrors
Generic Container CI/CD / test-build-publish (push) Successful in 25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 25s
2026-08-17 19:25:38 +08:00
jiang 7216ffd60f feat: add container CI/CD workflow 2026-08-17 19:02:02 +08:00
jiang b1b68d2754 fix: complete agent question interactions 2026-07-28 18:08:02 +08:00
jiang 2c35f500df fix: polish agent voice controls 2026-07-28 17:45:55 +08:00
jiang 3ba62ceacb fix: remove history item click outline 2026-07-28 17:41:04 +08:00
jiang 73896a893f fix: unify Agent history panel framing
The regression came from applying history-open styling to the outer acrylic shell during iteration. Keep the shell frame unchanged, confine the bottom edge treatment to the expanded header, and cover desktop and mobile geometry in Playwright.
2026-07-28 17:24:32 +08:00
jiang f4318a9b9c feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00
jiang ade88b4fbf fix: distinguish awaiting work order replies 2026-07-27 14:58:29 +08:00
jiang f8c79a2af3 fix: refine mobile workbench sheets 2026-07-27 12:56:03 +08:00
jiang f0485ca86e feat: refresh workbench visual layout 2026-07-27 11:50:29 +08:00
161 changed files with 12675 additions and 2626 deletions
+5 -1
View File
@@ -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_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater
TJWATER_GEOSERVER_WORKSPACE=tjwater_next
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
TJWATER_ENABLE_DEV_PANEL=false
TJWATER_ENABLE_MSW=false
+22
View File
@@ -0,0 +1,22 @@
name: Frontend CI/CD
on:
push:
tags:
- "v*"
workflow_dispatch: {}
jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-frontend
dockerfile: Dockerfile
build_context: .
test_target: build
deploy_service: next-frontend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
+2 -2
View File
@@ -4,8 +4,8 @@
This repository contains the Vite and React single-page frontend for the
Agent-driven supply-network WebGIS. Runtime TypeScript lives under `src/`.
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, deck.gl,
SWR, Vitest, and Playwright.
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, SWR,
Vitest, and Playwright.
Keep this project browser-first. Do not add Next.js APIs, `"use client"`
directives, or general backend-for-frontend routes. Agent reasoning and tool
+12 -4
View File
@@ -1,17 +1,23 @@
FROM node:24-alpine AS build
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
ENV npm_config_registry=${NPM_REGISTRY}
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
COPY . .
RUN pnpm build
FROM caddy:2.10.2-alpine
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
RUN apk add --no-cache jq nodejs
ARG ALPINE_MIRROR=mirrors.aliyun.com
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache jq nodejs
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
COPY --from=build /app/dist /srv
@@ -19,5 +25,7 @@ COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-fr
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+23 -4
View File
@@ -2,7 +2,7 @@
Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以地图为主视图,集成供水管网图层、调度状态、事件处置、Agent 对话和受控前端动作。
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图与流向效果基于 MapLibre GL,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
## 主要功能
@@ -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_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
`.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` 用于覆盖默认中文语音。
## 常用命令
@@ -70,6 +78,17 @@ docker run --rm -p 8080:80 --env-file .env.local next-tjwater
启动后访问 `http://localhost:8080`。
## CI/CD
Gitea Actions 通过 `.gitea/workflows/package.yml` 复用
`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev
服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会
提升为正式 `frontend` 服务。
仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和
`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。生产运行时配置由 Dev 服务器的
`env/frontend.env` 注入,不写入镜像或仓库 Secret。
## 项目结构
```text
@@ -87,4 +106,4 @@ src/
## 构建说明
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+9 -1
View File
@@ -2,16 +2,24 @@
set -eu
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 mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
'{
TJWATER_AUTH_MODE: $authMode,
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
TJWATER_ENABLE_MSW: $enableMsw
+227
View File
@@ -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-*` 类。
+40
View File
@@ -0,0 +1,40 @@
# 状态语言
系统使用五种语义状态。业务模块保留自己的领域枚举,并在展示边界映射到共享
`StatusTone`,不要让展示文案、接口字符串或 Tailwind 原色类直接决定状态样式。
| Tone | 用途 | 典型状态 |
| --- | --- | --- |
| `neutral` | 尚未开始或不再活动 | 预约、草案、待办、取消、中止、跳过、未分级 |
| `info` | 正在发生的过程 | 执行中、提交中、加载中、连接中 |
| `success` | 已完成或健康 | 完成、在线、正常、已批准 |
| `warning` | 需要关注但未达到异常 | 关注、降级、待复核、待复令、待确认 |
| `danger` | 已异常或操作失败 | 异常、失败、离线、高风险、事故、权限拒绝 |
## 报告风险
报告风险固定为三档:
- `normal` 映射 `success`,显示“正常”。
- `attention` 映射 `warning`,显示“关注”。
- `critical` 映射 `danger`,显示“异常”。
`info` 只表示过程,不参与风险等级。不得把 `attention` 合并到正常或异常。
## 表达规则
- 状态标签使用 `StatusBadge`,小型指示使用 `StatusDot`,图标节点使用 `StatusIcon`。
- 大面积容器只使用柔和背景,禁止实心红色或橙色状态卡片。
- `warning` 和 `danger` 必须同时提供文字或图标,不能只依赖颜色。
- 只有进行中的状态可以使用 `live` 或 `loading` 动效。
- “复令”是提交处置结果的动作,执行完成但尚未提交时显示“待复令”,使用静态
`warning`;不得继续显示为“执行中”或提前显示为完成态。
- 动效必须在 `prefers-reduced-motion` 下停止。
- 蓝色选中态属于交互反馈,不作为业务风险状态。
- 紫色只表示 Agent 身份,不表示未知或更高风险。
## 地图状态
地图与面板共享语义,但保留独立色值以适应浅色底图、影像底图和白色 casing。
新增地图状态时,应先补充 `map-colors.ts`,再通过对比度测试验证,不要在图层定义中
写入新的颜色字面量。
+7 -1
View File
@@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TJWater WebGIS Agent</title>
<link
rel="preload"
as="fetch"
href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf"
crossorigin="anonymous"
/>
<title>供水管网</title>
</head>
<body>
<div id="root"></div>
+5 -6
View File
@@ -6,7 +6,8 @@
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && pnpm build:tts",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
@@ -18,9 +19,7 @@
},
"dependencies": {
"@ai-sdk/react": "^4.0.9",
"@deck.gl/core": "^9.3.7",
"@deck.gl/layers": "^9.3.7",
"@deck.gl/mapbox": "^9.3.7",
"@fontsource-variable/noto-sans-sc": "5.3.0",
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17",
@@ -44,6 +43,7 @@
"echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0",
"keycloak-js": "26.2.4",
"lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1",
"motion": "^12.40.0",
@@ -72,14 +72,13 @@
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react-swc": "^4.0.2",
"esbuild": "^0.28.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"esbuild": "^0.28.0",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"msw": "^2.6.8",
"playwright": "^1.61.1",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
+9 -2
View File
@@ -2,18 +2,25 @@ import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
const runningInCi = Boolean(process.env.CI);
export default defineConfig({
testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true,
reporter: "html",
forbidOnly: runningInCi,
retries: runningInCi ? 1 : 0,
workers: runningInCi ? 2 : undefined,
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
use: {
baseURL,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
deviceScaleFactor: 1,
trace: "on-first-retry"
},
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,
reuseExistingServer: false
},
+17 -374
View File
@@ -11,15 +11,9 @@ importers:
'@ai-sdk/react':
specifier: ^4.0.9
version: 4.0.34(react@19.2.7)(zod@3.25.76)
'@deck.gl/core':
specifier: ^9.3.7
version: 9.3.7
'@deck.gl/layers':
specifier: ^9.3.7
version: 9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))
'@deck.gl/mapbox':
specifier: ^9.3.7
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
'@fontsource-variable/noto-sans-sc':
specifier: 5.3.0
version: 5.3.0
'@radix-ui/react-accordion':
specifier: ^1.2.14
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -89,6 +83,9 @@ importers:
katex:
specifier: ^0.17.0
version: 0.17.0
keycloak-js:
specifier: 26.2.4
version: 26.2.4
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
@@ -189,9 +186,6 @@ importers:
msw:
specifier: ^2.6.8
version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
playwright:
specifier: ^1.61.1
version: 1.61.1
prettier:
specifier: ^3.4.2
version: 3.9.5
@@ -454,24 +448,6 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@deck.gl/core@9.3.7':
resolution: {integrity: sha512-l+YPm1I0Fwj+b7xsu8FvyDT5MGPgEPJe2cQJUpQNgG742b2goB4tyYpteUhNAOeVo1gmenvqv36ZJGNsGxipJA==}
'@deck.gl/layers@9.3.7':
resolution: {integrity: sha512-T2CnkEU1QEqmusrSdot7+8Nl09gzntG5DvhaogyBbTjmABhTh4rUnS6dLOjf5/ju3aJ+Q58/FvDzgBBcK4V7kg==}
peerDependencies:
'@deck.gl/core': ~9.3.0
'@loaders.gl/core': ^4.4.3
'@luma.gl/core': ~9.3.3
'@luma.gl/engine': ~9.3.3
'@deck.gl/mapbox@9.3.7':
resolution: {integrity: sha512-hamgwBS2BbB8DHOjYWwPMrFki+KhcedjoHv/9cUpQHnjfXUswzmyhAURuyowO/tAVJb3vF+CmMzaDVUTlI40Sw==}
peerDependencies:
'@deck.gl/core': ~9.3.0
'@luma.gl/core': ~9.3.3
'@math.gl/web-mercator': ^4.1.0
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -681,6 +657,9 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@fontsource-variable/noto-sans-sc@5.3.0':
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -758,49 +737,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@loaders.gl/core@4.4.3':
resolution: {integrity: sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==}
'@loaders.gl/images@4.4.3':
resolution: {integrity: sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@loaders.gl/loader-utils@4.4.3':
resolution: {integrity: sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==}
'@loaders.gl/schema-utils@4.4.3':
resolution: {integrity: sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@loaders.gl/schema@4.4.3':
resolution: {integrity: sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==}
'@loaders.gl/worker-utils@4.4.3':
resolution: {integrity: sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@luma.gl/core@9.3.6':
resolution: {integrity: sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==}
'@luma.gl/engine@9.3.6':
resolution: {integrity: sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@luma.gl/shadertools': ~9.3.0
'@luma.gl/shadertools@9.3.6':
resolution: {integrity: sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@luma.gl/webgl@9.3.6':
resolution: {integrity: sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -829,21 +765,6 @@ packages:
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
hasBin: true
'@math.gl/core@4.1.0':
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
'@math.gl/polygon@4.1.0':
resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==}
'@math.gl/sun@4.1.0':
resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==}
'@math.gl/types@4.1.0':
resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==}
'@math.gl/web-mercator@4.1.0':
resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
@@ -871,15 +792,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@probe.gl/env@4.1.1':
resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==}
'@probe.gl/log@4.1.1':
resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==}
'@probe.gl/stats@4.1.1':
resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==}
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -1708,12 +1620,6 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/command-line-usage@5.0.4':
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1849,9 +1755,6 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
@@ -2084,10 +1987,6 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
apache-arrow@21.1.0:
resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==}
hasBin: true
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2102,10 +2001,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
array-back@6.2.3:
resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==}
engines: {node: '>=12.17'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2150,10 +2045,6 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
engines: {node: '>=12'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2205,19 +2096,6 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
command-line-args@6.0.2:
resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==}
engines: {node: '>=12.20'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
command-line-usage@7.0.4:
resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==}
engines: {node: '>=12.20.0'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -2475,9 +2353,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
earcut@2.2.4:
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
earcut@3.2.3:
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
@@ -2660,15 +2535,6 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
find-replace@5.0.2:
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
engines: {node: '>=14'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2677,9 +2543,6 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
@@ -2968,10 +2831,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'}
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -3003,6 +2862,9 @@ packages:
kdbush@4.1.0:
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
keycloak-js@26.2.4:
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3104,9 +2966,6 @@ packages:
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3355,9 +3214,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mjolnir.js@3.0.1:
resolution: {integrity: sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ==}
motion-dom@12.42.2:
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
@@ -3799,10 +3655,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -3897,10 +3749,6 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -4114,10 +3962,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4485,51 +4329,6 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@deck.gl/core@9.3.7':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
'@luma.gl/core': 9.3.6
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@luma.gl/webgl': 9.3.6(@luma.gl/core@9.3.6)
'@math.gl/core': 4.1.0
'@math.gl/sun': 4.1.0
'@math.gl/types': 4.1.0
'@math.gl/web-mercator': 4.1.0
'@probe.gl/env': 4.1.1
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@types/offscreencanvas': 2019.7.3
gl-matrix: 3.4.4
mjolnir.js: 3.0.1
transitivePeerDependencies:
- '@75lb/nature'
'@deck.gl/layers@9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))':
dependencies:
'@deck.gl/core': 9.3.7
'@loaders.gl/core': 4.4.3
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/schema': 4.4.3
'@luma.gl/core': 9.3.6
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@mapbox/tiny-sdf': 2.2.0
'@math.gl/core': 4.1.0
'@math.gl/polygon': 4.1.0
'@math.gl/web-mercator': 4.1.0
'@types/geojson': 7946.0.16
earcut: 2.2.4
transitivePeerDependencies:
- '@75lb/nature'
'@deck.gl/mapbox@9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)':
dependencies:
'@deck.gl/core': 9.3.7
'@luma.gl/core': 9.3.6
'@math.gl/web-mercator': 4.1.0
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -4671,6 +4470,8 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@fontsource-variable/noto-sans-sc@5.3.0': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -4741,82 +4542,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@loaders.gl/core@4.4.3':
dependencies:
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/schema': 4.4.3
'@loaders.gl/schema-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@probe.gl/log': 4.1.1
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/images@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/loader-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/schema': 4.4.3
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
transitivePeerDependencies:
- '@75lb/nature'
- '@loaders.gl/core'
'@loaders.gl/schema-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/schema': 4.4.3
'@types/geojson': 7946.0.16
apache-arrow: 21.1.0
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/schema@4.4.3':
dependencies:
'@types/geojson': 7946.0.16
apache-arrow: 21.1.0
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/worker-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@luma.gl/core@9.3.6':
dependencies:
'@math.gl/types': 4.1.0
'@probe.gl/env': 4.1.1
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@types/offscreencanvas': 2019.7.3
'@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))':
dependencies:
'@luma.gl/core': 9.3.6
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@math.gl/core': 4.1.0
'@math.gl/types': 4.1.0
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)':
dependencies:
'@luma.gl/core': 9.3.6
'@math.gl/core': 4.1.0
'@math.gl/types': 4.1.0
'@luma.gl/webgl@9.3.6(@luma.gl/core@9.3.6)':
dependencies:
'@luma.gl/core': 9.3.6
'@math.gl/types': 4.1.0
'@probe.gl/env': 4.1.1
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -4846,22 +4571,6 @@ snapshots:
rw: 1.3.3
tinyqueue: 3.0.0
'@math.gl/core@4.1.0':
dependencies:
'@math.gl/types': 4.1.0
'@math.gl/polygon@4.1.0':
dependencies:
'@math.gl/core': 4.1.0
'@math.gl/sun@4.1.0': {}
'@math.gl/types@4.1.0': {}
'@math.gl/web-mercator@4.1.0':
dependencies:
'@math.gl/core': 4.1.0
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -4894,14 +4603,6 @@ snapshots:
dependencies:
playwright: 1.61.1
'@probe.gl/env@4.1.1': {}
'@probe.gl/log@4.1.1':
dependencies:
'@probe.gl/env': 4.1.1
'@probe.gl/stats@4.1.1': {}
'@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {}
@@ -5542,6 +5243,7 @@ snapshots:
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
optional: true
'@swc/types@0.1.27':
dependencies:
@@ -5656,10 +5358,6 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/command-line-args@5.2.3': {}
'@types/command-line-usage@5.0.4': {}
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@@ -5821,8 +5519,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/offscreencanvas@2019.7.3': {}
'@types/pbf@3.0.5': {}
'@types/react-dom@19.2.3(@types/react@19.2.17)':
@@ -6001,20 +5697,6 @@ snapshots:
ansi-styles@5.2.0: {}
apache-arrow@21.1.0:
dependencies:
'@swc/helpers': 0.5.23
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 24.13.3
command-line-args: 6.0.2
command-line-usage: 7.0.4
flatbuffers: 25.9.23
json-bignum: 0.0.3
tslib: 2.8.1
transitivePeerDependencies:
- '@75lb/nature'
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -6027,8 +5709,6 @@ snapshots:
aria-query@5.3.2: {}
array-back@6.2.3: {}
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
@@ -6065,10 +5745,6 @@ snapshots:
chai@6.2.2: {}
chalk-template@0.4.0:
dependencies:
chalk: 4.1.2
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -6120,20 +5796,6 @@ snapshots:
comma-separated-tokens@2.0.3: {}
command-line-args@6.0.2:
dependencies:
array-back: 6.2.3
find-replace: 5.0.2
lodash.camelcase: 4.3.0
typical: 7.3.0
command-line-usage@7.0.4:
dependencies:
array-back: 6.2.3
chalk-template: 0.4.0
table-layout: 4.1.1
typical: 7.3.0
commander@14.0.3: {}
commander@7.2.0: {}
@@ -6402,8 +6064,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
earcut@2.2.4: {}
earcut@3.2.3: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
@@ -6615,8 +6275,6 @@ snapshots:
dependencies:
flat-cache: 4.0.1
find-replace@5.0.2: {}
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -6627,8 +6285,6 @@ snapshots:
flatted: 3.4.2
keyv: 4.5.4
flatbuffers@25.9.23: {}
flatted@3.4.2: {}
form-data@4.0.6:
@@ -6973,8 +6629,6 @@ snapshots:
jsesc@3.1.0: {}
json-bignum@0.0.3: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -6997,6 +6651,8 @@ snapshots:
kdbush@4.1.0: {}
keycloak-js@26.2.4: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -7069,8 +6725,6 @@ snapshots:
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.merge@4.6.2: {}
longest-streak@3.1.0: {}
@@ -7586,8 +7240,6 @@ snapshots:
minimist@1.2.8: {}
mjolnir.js@3.0.1: {}
motion-dom@12.42.2:
dependencies:
motion-utils: 12.39.0
@@ -8091,11 +7743,6 @@ snapshots:
symbol-tree@3.2.4: {}
table-layout@4.1.1:
dependencies:
array-back: 6.2.3
wordwrapjs: 5.1.1
tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {}
@@ -8186,8 +7833,6 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
typical@7.3.0: {}
undici-types@7.18.2: {}
unified@11.0.5:
@@ -8369,8 +8014,6 @@ snapshots:
word-wrap@1.2.5: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
+6 -1
View File
@@ -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"
};
+35
View File
@@ -0,0 +1,35 @@
import { readdir, readFile } from "node:fs/promises";
import { stdout } from "node:process";
import { fileURLToPath, URL } from "node:url";
import path from "node:path";
const assetsDirectory = fileURLToPath(new URL("../dist/assets/", import.meta.url));
const assetNames = await readdir(assetsDirectory);
const stylesheetName = assetNames.find((name) => /^index-.*\.css$/.test(name));
if (!stylesheetName) {
throw new Error("Production CSS verification failed: index stylesheet was not emitted.");
}
const stylesheet = await readFile(path.join(assetsDirectory, stylesheetName), "utf8");
const selector = ".agent-panel-shell.acrylic-panel";
const selectorOffset = stylesheet.indexOf(selector);
const ruleStart = stylesheet.indexOf("{", selectorOffset);
const ruleEnd = stylesheet.indexOf("}", ruleStart);
if (selectorOffset < 0 || ruleStart < 0 || ruleEnd < 0) {
throw new Error(`Production CSS verification failed: ${selector} rule is missing.`);
}
const declarations = stylesheet
.slice(ruleStart + 1, ruleEnd)
.split(";")
.map((declaration) => declaration.trim());
if (!declarations.some((declaration) => declaration.startsWith("backdrop-filter:"))) {
throw new Error(
"Production CSS verification failed: the standard backdrop-filter declaration was removed."
);
}
stdout.write(`Verified standard backdrop-filter in ${stylesheetName}.\n`);
+16 -7
View File
@@ -17,6 +17,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
expect(runtimeConfig).toMatchObject({
TJWATER_MAP_URL: expect.any(String),
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
TJWATER_SERVER_API_BASE_URL: expect.any(String),
TJWATER_AGENT_API_BASE_URL: expect.any(String)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
@@ -80,7 +81,7 @@ test("preserves compact typography in header and agent controls", async ({ page
await expect(approvalControl).toHaveCSS("font-size", "12px");
await expect(approvalControl).toHaveCSS("line-height", "16px");
await expect(suggestion).toHaveCSS("font-size", "12px");
await expect(suggestion).toHaveCSS("font-weight", "500");
await expect(suggestion).toHaveCSS("font-weight", "600");
});
test("lets interactive utilities override surface materials", async ({ page }) => {
@@ -100,7 +101,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
expect(hoverBackground).not.toBe(restingBackground);
});
test("preserves v3 menu and panel 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.goto("/");
@@ -112,10 +113,18 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const navigationMaterial = await navigation.evaluate(
(element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
}
);
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
expect(commandShadow).not.toContain("0px 1px 3px 0px");
expect(navigationMaterial.backdropFilter).toBe("none");
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
View File
@@ -1,11 +1,16 @@
import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench";
import type { Authentication } from "@/shared/auth/keycloak-auth";
import { AppProviders } from "./providers";
export function App() {
export function App({ authentication }: { authentication: Authentication }) {
return (
<AppProviders>
<MapWorkbenchPage />
<MapWorkbenchPage
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
/>
<MapToaster />
</AppProviders>
);
+26
View File
@@ -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>
);
}
+17
View File
@@ -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 () => {
vi.stubGlobal(
"fetch",
+63 -79
View File
@@ -1,4 +1,5 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
@@ -112,19 +113,47 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
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(/\/$/, ""));
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 {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
return requestJson<unknown>("/frontend-action-registry");
},
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",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
const payload = await requestJson<unknown>("/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
return requestJson<unknown>("/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
return requestJson<unknown>(
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
await requestJson<unknown>("/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
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(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
init?: RequestInit,
getAccessToken?: AccessTokenProvider
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -359,6 +337,12 @@ async function fetchWithFallback(
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) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
@@ -1,7 +1,6 @@
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/shared/ui/cn";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
@@ -21,8 +20,7 @@ export function AgentCollapsedRail({
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
"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
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
@@ -1,6 +1,7 @@
import {
Activity,
CheckCircle2,
Clock3,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
@@ -15,10 +16,11 @@ import {
Shield,
ShieldCheck,
Square,
Volume2
Volume2,
X
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
@@ -47,6 +49,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
@@ -69,6 +72,7 @@ import {
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
@@ -77,7 +81,17 @@ import type {
AgentUiResult
} from "../types";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
export type AgentWorkspaceContextItem = {
id: string;
label: string;
value: string;
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
@@ -85,6 +99,7 @@ type AgentCommandPanelProps = {
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
@@ -92,6 +107,7 @@ type AgentCommandPanelProps = {
selectedModel?: string;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
workspaceContext?: AgentWorkspaceContextItem[];
onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
@@ -127,6 +143,7 @@ const AGENT_CAPABILITIES = [
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
personaState,
sessionTitle = "新对话",
@@ -134,6 +151,7 @@ export function AgentCommandPanel({
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
messages = [],
streamRenderState = {},
@@ -141,6 +159,7 @@ export function AgentCommandPanel({
selectedModel = "",
approvalMode = "request",
uiResults = [],
workspaceContext = [],
onCollapse,
onRefreshHistory,
onStartNewSession,
@@ -159,6 +178,12 @@ export function AgentCommandPanel({
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const historyPanelId = useId();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const trimmedPrompt = prompt.trim();
const {
speechState,
@@ -194,6 +219,46 @@ export function AgentCommandPanel({
}
}, [historyOpen, onRefreshHistory]);
useEffect(() => {
if (!historyOpen) {
return;
}
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (
historyPanelRef.current?.contains(target) ||
historyTriggerRef.current?.contains(target)
) {
return;
}
setHistoryOpen(false);
};
const closeHistoryOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
setHistoryOpen(false);
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
};
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.addEventListener("keydown", closeHistoryOnEscape);
return () => {
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.removeEventListener("keydown", closeHistoryOnEscape);
};
}, [historyOpen]);
useEffect(() => {
stopSpeech();
}, [activeSessionId, stopSpeech]);
@@ -213,21 +278,62 @@ export function AgentCommandPanel({
setPrompt(nextPrompt);
});
};
const historyPanel = (
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
);
const operationalBrief = (
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
);
return (
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
"acrylic-panel border",
"rounded-2xl",
presentation === "desktop-dock"
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
: desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent",
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header">
<Agent
className={cn(
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
floatingPresentation && "gap-1 overflow-visible p-1"
)}
>
<div className="relative z-30 h-16 shrink-0">
<header
className={cn(
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
floatingPresentation && "agent-panel-integrated-header",
presentation === "mobile-sheet" && "agent-panel-header-mobile",
historyOpen && "agent-panel-header-expanded"
)}
>
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
@@ -237,15 +343,17 @@ export function AgentCommandPanel({
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
对话主题
</p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
getAgentConnectionClassName(statusLabel)
)}
<StatusBadge
tone={agentConnectionMetadata[connectionStatus].tone}
icon={
<StatusDot
tone={agentConnectionMetadata[connectionStatus].tone}
activity={connectionStatus === "connecting" ? "live" : "static"}
/>
{getAgentConnectionLabel(statusLabel)}
</span>
}
>
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
@@ -265,86 +373,143 @@ export function AgentCommandPanel({
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
ref={historyTriggerRef}
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
aria-expanded={historyOpen}
aria-controls={historyPanelId}
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<History size={17} aria-hidden="true" />
</button>
{presentation === "mobile-sheet" ? (
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<X size={17} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
)}
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
ref={historyPanelRef}
key="history"
id={historyPanelId}
role="region"
aria-label="Agent 历史记录"
className="agent-panel-floating-acrylic agent-panel-history-extension"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
{historyPanel}
</motion.div>
) : null}
</AnimatePresence>
</header>
</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 ? (
<AnimatePresence initial={false}>
{hasConversation && !historyOpen ? (
<motion.div
key="operational-brief"
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
) : (
<AnimatePresence initial={false}>
{hasConversation ? (
<motion.div
key="operational-brief"
aria-hidden={historyOpen}
className={cn(
"space-y-2 px-3 pb-3",
historyOpen && "invisible pointer-events-none"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
)}
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<Conversation
className={cn(
"agent-panel-conversation min-h-0",
floatingPresentation && "agent-panel-conversation-canvas"
)}
>
<ConversationContent
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
className={cn(
"gap-4 p-3",
messages.length === 0 && "min-h-full",
floatingPresentation && "agent-panel-conversation-content",
floatingPresentation &&
hasConversation &&
"agent-panel-conversation-content-has-brief",
floatingPresentation &&
!hasConversation &&
"agent-panel-conversation-content-empty"
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
@@ -373,7 +538,7 @@ export function AgentCommandPanel({
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
</AnimatePresence>
@@ -384,14 +549,32 @@ export function AgentCommandPanel({
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
/>
<ConversationScrollButton
className={cn(
"agent-panel-floating-acrylic agent-conversation-scroll-button",
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
)}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<div
className={cn(
"agent-panel-band relative",
floatingPresentation
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-slate-200 p-3"
)}
>
{!hasConversation ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
)}
>
<Suggestions
aria-label="推荐问题"
role="group"
@@ -411,7 +594,7 @@ export function AgentCommandPanel({
</div>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
@@ -423,7 +606,7 @@ export function AgentCommandPanel({
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
@@ -435,7 +618,7 @@ export function AgentCommandPanel({
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
@@ -458,7 +641,13 @@ export function AgentCommandPanel({
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
{streaming ? undefined : (
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
@@ -497,29 +686,38 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
"border-transparent bg-transparent text-blue-600 shadow-none",
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
)}
disabled={disabled}
onClick={onClick}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
@@ -539,7 +737,7 @@ function VoiceInputButton({
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-red-500"
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
@@ -598,7 +796,8 @@ function ApprovalModeSelect({
<Button
type="button"
variant="outline"
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -639,17 +838,24 @@ function ApprovalModeSelect({
<DropdownMenuItem
className={cn(
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
</span>
{!requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -659,29 +865,36 @@ function ApprovalModeSelect({
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastMessageRevision: string;
lastUserMessageId: string | null;
scrollRequestId: number;
streaming: boolean;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId
scrollRequestId,
streaming
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
}) {
const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const lastUserMessageId = getLastUserMessageId(messages);
const lastMessageRevision = getLastMessageRevision(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastMessageRevision,
lastUserMessageId,
scrollRequestId
scrollRequestId,
streaming
}),
[activeSessionId, lastUserMessageId, scrollRequestId]
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
);
useEffect(() => {
@@ -693,7 +906,10 @@ function AgentConversationScrollManager({
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) &&
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
(snapshot.streaming &&
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
previousSnapshot.streaming !== snapshot.streaming;
if (!forceScroll) {
return;
@@ -713,6 +929,19 @@ function AgentConversationScrollManager({
return null;
}
function getLastMessageRevision(messages: AgentChatMessage[]) {
const lastMessage = messages.at(-1);
if (!lastMessage) {
return "";
}
const progressRevision =
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
@@ -746,7 +975,8 @@ function AgentModelSelect({
<Button
type="button"
variant="outline"
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
@@ -801,13 +1031,11 @@ function AgentModelSelect({
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
@@ -875,50 +1103,66 @@ function AgentReadyMark() {
);
}
function AgentEmptyState() {
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
className="mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-4 py-5">
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
<div className="flex items-center gap-4">
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
<div
className={cn(
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
)}
>
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-900"
className={cn(
"text-balance font-semibold text-slate-950",
compact ? "text-base leading-6" : "text-lg leading-7"
)}
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
<p
className={cn(
"text-pretty text-left text-sm leading-6 text-[#334155]",
compact ? "mt-3" : "mt-5"
)}
>
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
<ul
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
className={cn(
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={cn(
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-200/70",
index < 2 && "border-b border-slate-200/70"
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-300/80",
index < 2 && "border-b border-slate-300/80"
)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
@@ -980,9 +1224,10 @@ function BackendMessageList({
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "user" &&
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
)}
>
<div className="space-y-3">
@@ -1168,21 +1413,16 @@ function AgentSpeechMessage({
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
@@ -1270,20 +1510,21 @@ function SpeechIconButton({
children: ReactNode;
}) {
return (
<button
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="icon-sm"
variant="ghost"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
tone === "primary" && "text-blue-600",
tone === "danger" && "text-red-600"
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</button>
</Button>
);
}
@@ -1,8 +1,10 @@
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
@@ -73,30 +75,35 @@ export function AgentHistoryPanel({
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950">历史记录</p>
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
<p className="mt-0.5 text-xs text-slate-500">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
</div>
<button
<Button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
size="icon"
variant="ghost"
disabled={loading && sessions.length === 0}
loading={loading && sessions.length > 0}
loadingLabel="正在刷新 Agent 历史记录"
onClick={() => void Promise.resolve(onRefresh?.())}
>
{loading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={15} aria-hidden="true" />
)}
</button>
</Button>
</div>
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
<motion.div
className="max-h-56 space-y-1 overflow-y-auto pr-1"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
initial="initial"
animate="animate"
exit="exit"
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
key={session.id}
layout
className={cn(
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing
? "agent-history-item-active border border-blue-200"
: "border border-transparent"
)}
initial="initial"
animate="animate"
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
<motion.div
key="confirm-delete"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<span
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
title={session.title}
>
删除“{session.title}”?
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
>
<Input
autoFocus
className="h-8 rounded-lg bg-white text-xs"
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
@@ -168,7 +187,7 @@ export function AgentHistoryPanel({
<motion.button
key="session-summary"
type="button"
className="min-w-0 text-left"
className="agent-history-session-trigger min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
@@ -176,7 +195,10 @@ export function AgentHistoryPanel({
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
<span
className="block truncate text-xs font-semibold text-slate-900"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -185,79 +207,89 @@ export function AgentHistoryPanel({
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-1">
<span className="flex items-center gap-2">
{session.runStatus === "running" || session.isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<StatusDot tone="info" activity="live" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
<Loader2
size={13}
className="animate-spin text-blue-600"
aria-hidden="true"
/>
) : null}
{active && !editing && !confirmingDelete ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
当前
</span>
<StatusBadge tone="info">当前</StatusBadge>
) : null}
{confirmingDelete ? (
<>
<button
<Button
type="button"
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
disabled={deleting}
variant="destructive"
size="sm"
loading={deleting}
loadingLabel="正在删除对话"
onClick={() => confirmDelete(session.id)}
>
确认
</button>
<button
</Button>
<Button
type="button"
aria-label="取消删除"
title="取消删除"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : editing ? (
<>
<button
<Button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
disabled={saving}
size="icon-sm"
loading={saving}
loadingLabel="正在保存对话主题"
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="取消重命名"
title="取消重命名"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : (
<>
<button
<Button
type="button"
aria-label="重命名对话"
title="重命名对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="删除对话"
title="删除对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
size="icon-sm"
variant="ghost"
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
@@ -265,7 +297,7 @@ export function AgentHistoryPanel({
}}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</Button>
</>
)}
</span>
@@ -273,7 +305,14 @@ export function AgentHistoryPanel({
);
})
) : (
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
暂无历史记录
</motion.div>
)}
@@ -0,0 +1,90 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageDetails } from "./agent-message-details";
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
afterEach(cleanup);
describe("AgentMessageDetails question answers", () => {
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析范围",
question: "你想分析哪一部分?",
options: [
{ label: "雨污混接分析", description: "检查混接问题" },
{ label: "水质异常分析", description: "检查水质指标" }
]
},
onReplyQuestion
);
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["检查高峰时段压力"]]
);
});
it("combines selected options with a custom answer for a multiple-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析内容",
question: "需要包含哪些内容?",
options: [
{ label: "压力", description: "分析节点压力" },
{ label: "流量", description: "分析管段流量" }
],
multiple: true,
custom: true
},
onReplyQuestion
);
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["压力", "补充水龄"]]
);
});
});
function renderQuestion(
question: AgentQuestionInfo,
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
) {
render(
<AgentMessageDetails
message={{
id: "assistant-1",
role: "assistant",
content: "需要补充信息",
questions: [
{
requestId: "question-1",
sessionId: "session-1",
questions: [question],
createdAt: 100,
status: "pending"
}
]
}}
onReplyQuestion={onReplyQuestion}
/>
);
}
@@ -10,9 +10,16 @@ import {
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { useId, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusActivity,
type StatusTone
} from "@/shared/ui/status";
import {
agentEase,
agentLayoutTransition,
@@ -37,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
const STREAMING_PROGRESS_LIMIT = 3;
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
type AgentStatusMeta = {
label: string;
tone: StatusTone;
activity?: StatusActivity;
};
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
label: "已完成",
tone: "success"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
tone: "danger"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
tone: "success"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
tone: "neutral"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
tone: "neutral"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
approved_always: { label: "已始终允许", tone: "success" },
approved_once: { label: "已允许一次", tone: "success" },
rejected: { label: "已拒绝", tone: "danger" },
aborted: { label: "已中止", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待确认", tone: "warning" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待回答", tone: "info" }
};
export function AgentMessageDetails({
@@ -198,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const listId = useId();
const compact = running && !expanded;
const visibleProgress = expanded
? progress
@@ -207,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
{running ? (
<StatusDot tone="info" activity="live" aria-hidden="true" />
) : null}
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
<span className="font-mono tabular-nums text-slate-600">
{expanded || !running
? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
<span className="px-0.5 text-slate-300">/</span>
{progress.length}
</span>
</span>
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
className={cn(
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
expanded && "text-blue-500"
)}
aria-controls={listId}
aria-expanded={expanded}
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
data-state={expanded ? "open" : "closed"}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span>执行进度</span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length} 条`
: running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
: `共 ${progress.length} 条`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
className={cn("transition-transform duration-150", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
</div>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
id={listId}
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
<motion.span
aria-hidden="true"
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
progressStatusMeta[item.status].dotClassName
)}
animate={
item.status === "running"
? { opacity: [0.45, 1, 0.45], scale: 1 }
: item.status === "error"
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
: { opacity: 1, scale: 1 }
}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: item.status === "error"
? { duration: 0.28, ease: agentEase }
: { duration: 0.14 }
}
<StatusDot
tone={progressStatusMeta[item.status].tone}
activity={progressStatusMeta[item.status].activity}
size="md"
className="mt-1"
/>
<span className="min-w-0">
<span
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
progressStatusMeta[item.status].className
"min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running"
? "text-blue-600"
: [
`status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]"
]
)}
initial={{ opacity: 0 }}
animate={
item.status === "error"
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
: { opacity: 1, x: 0 }
}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={
item.status === "error"
? { duration: 0.24, ease: agentEase }
: { duration: 0.14 }
}
transition={{ duration: 0.14 }}
>
{progressStatusMeta[item.status].label}
</motion.span>
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
<SemanticStatusIcon
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
shape="circle"
size="xs"
className="mt-0.5"
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
</SemanticStatusIcon>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
<StatusBadge
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
>
{todoStatusMeta[todo.status].label}
</span>
</StatusBadge>
</motion.li>
))}
</AnimatePresence>
@@ -376,7 +385,7 @@ function PermissionList({
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
@@ -424,18 +433,22 @@ function PermissionRequestCard({
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
<StatusBadge
tone={permissionStatusMeta[permission.status].tone}
activity={permissionStatusMeta[permission.status].activity}
className="self-start"
>
{permissionStatusMeta[permission.status].label}
</span>
</StatusBadge>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -445,7 +458,7 @@ function PermissionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
@@ -457,7 +470,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
@@ -469,7 +482,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
disabled={disabled}
onClick={() => reply("reject")}
>
@@ -547,9 +560,12 @@ function QuestionRequestCard({
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
<StatusBadge
tone={questionStatusMeta[request.status].tone}
activity={questionStatusMeta[request.status].activity}
>
{questionStatusMeta[request.status].label}
</span>
</StatusBadge>
</div>
<div className="space-y-3">
@@ -558,7 +574,7 @@ function QuestionRequestCard({
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
@@ -568,7 +584,7 @@ function QuestionRequestCard({
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
@@ -580,7 +596,7 @@ function QuestionRequestCard({
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -590,7 +606,7 @@ function QuestionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
@@ -601,7 +617,7 @@ function QuestionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
@@ -617,6 +633,7 @@ function QuestionRequestCard({
type QuestionDraftAnswer = {
selected: string[];
customSelected: boolean;
custom: string;
};
@@ -633,8 +650,11 @@ function QuestionInput({
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const inputId = useId();
const name = `agent-question-${questionIndex}-${inputId}`;
const customEnabled = question.custom !== false;
const showCustomChoice = customEnabled && question.options.length > 0;
const showCustomInput = customEnabled && value.customSelected;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
@@ -646,21 +666,36 @@ function QuestionInput({
}
onChange({
...value,
selected: checked ? [label] : []
selected: checked ? [label] : [],
customSelected: false
});
};
const toggleCustom = (checked: boolean) => {
onChange({
...value,
selected: question.multiple ? value.selected : [],
customSelected: checked
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
{question.options.length || showCustomChoice ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
checked
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -679,12 +714,34 @@ function QuestionInput({
</label>
);
})}
{showCustomChoice ? (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
value.customSelected
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="h-3.5 w-3.5 accent-blue-600"
checked={value.customSelected}
disabled={disabled}
onChange={(event) => toggleCustom(event.currentTarget.checked)}
/>
<span className="font-semibold text-slate-700">自定义回答</span>
</label>
) : null}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
aria-label="自定义回答内容"
placeholder="输入自定义回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
@@ -695,16 +752,25 @@ function QuestionInput({
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
return request.questions.map((question, index) => {
const optionLabels = new Set(question.options.map((option) => option.label));
const savedAnswers = request.answers?.[index] ?? [];
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
const customEnabled = question.custom !== false;
return {
selected,
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
custom: customAnswers.join(";")
};
});
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
});
}
@@ -1,15 +1,9 @@
import {
ClipboardCheck,
Crosshair,
MapPinned,
Radio,
Route
} from "lucide-react";
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import type {
AgentChatMessage
} from "../types";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
import type { AgentChatMessage } from "../types";
export function AgentOperationalBrief({
messages,
@@ -26,52 +20,71 @@ export function AgentOperationalBrief({
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="flex items-start gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
{brief.task}
</p>
</div>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
<AgentBriefMetric
icon={<Radio size={13} aria-hidden="true" />}
label="证据"
value={brief.evidence}
/>
<AgentBriefMetric
icon={<Route size={13} aria-hidden="true" />}
label="计划"
value={brief.plan}
/>
<AgentBriefMetric
icon={<ClipboardCheck size={13} aria-hidden="true" />}
label="确认"
value={brief.confirmation}
/>
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
<Button
type="button"
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
预览影响
</button>
<button
</Button>
<Button
type="button"
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
variant="outline"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
检查证据
</button>
</Button>
</div>
</div>
);
}
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
function AgentBriefMetric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
);
}
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
function createOperationalBrief(
messages: AgentChatMessage[],
statusLabel: string,
streaming: boolean
) {
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
const questionItems = messages.flatMap((message) => message.questions ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations =
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
const pendingConfirmations = countPendingAgentInputs(messages);
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const plan =
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
const stateLabel =
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
plan,
confirmation,
stateLabel,
statusDotClassName,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
@@ -282,7 +283,7 @@ function TrustedPanelShell({
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
{title}
</div>
{children}
@@ -62,7 +62,7 @@ function createRequest(name: string) {
toolCallId: "call-1",
sessionId: "session-1",
name,
params: { items: [{ sensor_id: "MP01", level: "high" }] },
params: { items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(),
expiresAt: Date.now() + 15_000
};
+7
View File
@@ -1,9 +1,15 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
AgentCommandPanelPresentation,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary,
AgentLoadedChatSession,
AgentSessionStreamEvent
@@ -55,6 +61,7 @@ export type {
AgentApprovalMode,
AgentChatMessage,
AgentChatProgress,
AgentConnectionStatus,
AgentInteractionToolRef,
AgentModelOption,
AgentPermissionReply,
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
import type { AgentChatMessage } from "./types";
describe("Agent message state", () => {
it("counts pending and failed operator inputs from one shared rule", () => {
const messages = [
{
id: "assistant-1",
role: "assistant",
content: "",
permissions: [
{ status: "pending" },
{ status: "approved_once" }
],
questions: [
{ status: "error" },
{ status: "answered" }
]
}
] as AgentChatMessage[];
expect(countPendingAgentInputs(messages)).toBe(2);
expect(hasPendingAgentInput(messages)).toBe(true);
expect(hasPendingAgentInput([])).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { AgentChatMessage } from "./types";
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
return messages.reduce((total, message) => {
const pendingPermissions =
message.permissions?.filter(
(permission) => permission.status === "pending" || permission.status === "error"
).length ?? 0;
const pendingQuestions =
message.questions?.filter(
(question) => question.status === "pending" || question.status === "error"
).length ?? 0;
return total + pendingPermissions + pendingQuestions;
}, 0);
}
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
return countPendingAgentInputs(messages) > 0;
}
+27
View File
@@ -94,6 +94,33 @@ describe("agent session state", () => {
});
});
it("preserves an actionable question id when its tool placeholder arrives later", () => {
const questions = upsertQuestion(undefined, {
session_id: "session-1",
request_id: "question-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 100
});
const withPlaceholder = upsertQuestion(questions, {
session_id: "session-1",
request_id: "call-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 120
});
const rejected = applyQuestionResponse(withPlaceholder, {
request_id: "question-1",
rejected: true
});
expect(withPlaceholder).toHaveLength(1);
expect(rejected?.[0]).toMatchObject({
requestId: "question-1",
status: "rejected"
});
});
it("finalizes open assistant state after abort", () => {
const message: AgentChatMessage = {
id: "assistant-1",
+16 -3
View File
@@ -183,10 +183,23 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
next[index] = {
...next[index],
const current = next[index];
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
next[index] =
currentIsActionable && nextIsToolPlaceholder
? {
...current,
sessionId: nextItem.sessionId,
questions: nextItem.questions,
tool: nextItem.tool ?? current.tool,
createdAt: nextItem.createdAt
}
: {
...current,
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
status: current.status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
+2
View File
@@ -14,6 +14,8 @@ export type AgentStreamRenderMessageState = {
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
export type AgentConnectionStatus = "connecting" | "online" | "offline";
export type AgentChatProgress = {
id: string;
phase: string;
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
layer: "pipes",
fallbackText: undefined
});
});
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
layer: "junctions"
});
});
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
type: "locate_features",
featureIds: ["S-1"],
layer: "geo_scada"
layer: "scada"
});
});
+9 -18
View File
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_scada: "geo_scada",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
locate_junctions: "junctions",
locate_pipes: "pipes",
locate_valves: "valves",
locate_reservoirs: "reservoirs",
locate_scada: "scada",
locate_pumps: "pumps",
locate_tanks: "tanks"
};
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS,
"pumps",
"tanks",
"geo_junctions_mat",
"geo_pipes_mat",
"geo_valves",
"geo_reservoirs",
"geo_scada",
"geo_pumps",
"geo_tanks"
...SUPPLY_SOURCE_IDS
]);
const WEB_MERCATOR_RADIUS = 6378137;
@@ -158,7 +158,7 @@ export function MapActionRow({
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
@@ -172,7 +172,7 @@ export function MapActionRow({
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
disabled && "active:translate-y-0 active:scale-100"
disabled && "shadow-none"
)}
>
<span
@@ -53,7 +53,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.active
? "border-blue-600 bg-blue-600 text-white shadow-md shadow-blue-600/20"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700",
@@ -85,7 +85,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
role="toolbar"
aria-label={label}
className={cn(
"pointer-events-auto inline-flex p-1",
"pointer-events-auto inline-flex gap-1 p-1",
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
className
@@ -105,14 +105,13 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
disabled={item.disabled}
onClick={item.onClick}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
item.active
? "bg-blue-600 text-white shadow-xs"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-blue-50 hover:text-blue-700",
isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)}
>
<Icon size={16} aria-hidden="true" />
@@ -61,7 +61,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
title={item.description}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]",
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
+2 -1
View File
@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusDot } from "@/shared/ui/status";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -66,7 +67,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
</span>
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
</li>
);
})}
@@ -1,3 +1,5 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
@@ -24,4 +26,4 @@ export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME =
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
buttonInteractionVariants({ tone: "neutral" });
@@ -117,7 +117,7 @@ export function MapMeasurePanel({
type="button"
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-7 text-xs font-semibold transition",
"h-10 text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
"absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
@@ -1,6 +1,11 @@
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -8,37 +13,13 @@ import {
} from "./map-control-styles";
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
const noticeTones = {
info: "info",
success: "success",
warning: "warning",
error: "danger",
loading: "info"
} satisfies Record<MapNoticeTone, StatusTone>;
export function MapNoticeContent({
title,
@@ -55,7 +36,11 @@ export function MapNoticeContent({
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
action={
actionLabel && onAction ? (
<MapNoticeAction label={actionLabel} onClick={onAction} />
) : null
}
/>
</MapNoticeFrame>
);
@@ -75,29 +60,31 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
function MapNoticeIcon({
tone,
icon: Icon
}: {
tone: MapNoticeTone;
icon: ReturnType<typeof getNoticeIcon>;
}) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
<SemanticStatusIcon
tone={noticeTones[tone]}
activity={tone === "loading" ? "loading" : "static"}
size="md"
className={cn("mt-0.5", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
<Icon size={17} />
</SemanticStatusIcon>
);
}
@@ -112,8 +99,12 @@ function MapNoticeBody({
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{title ? (
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
) : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
{message}
</span>
{action}
</span>
);
@@ -121,13 +112,15 @@ function MapNoticeBody({
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
<Button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
size="sm"
variant="outline"
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
</Button>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function MapToaster() {
classNames: {
toast: "group pointer-events-auto",
closeButton:
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
}
}}
/>
+6 -7
View File
@@ -44,10 +44,10 @@ export function MapToolbar({
<nav
aria-label={label}
className={cn(
"pointer-events-auto p-[3px]",
"pointer-events-auto p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
isVertical ? "flex w-12 flex-col" : "inline-flex",
className
)}
>
@@ -67,19 +67,18 @@ export function MapToolbar({
disabled={tool.disabled}
onClick={tool.onClick}
className={cn(
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
tool.active
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
)}
>
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
<span className="sr-only">{tool.label}</span>
{tool.badge ? (
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
<span className="status-tone-danger absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-[var(--status-mark)] px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
{tool.badge}
</span>
) : null}
+5 -6
View File
@@ -37,13 +37,13 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
role="group"
aria-label="地图缩放"
className={cn(
"pointer-events-auto flex w-[46px] flex-col gap-2",
"pointer-events-auto flex w-12 flex-col gap-2",
className
)}
>
<div
className={cn(
"flex flex-col p-[3px]",
"flex flex-col p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
@@ -65,7 +65,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
<div
className={cn(
"p-[3px]",
"p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
@@ -99,13 +99,12 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
disabled={disabled}
onClick={onClick}
className={cn(
"grid h-[38px] w-[38px] place-items-center transition duration-150",
"grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
"bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME,
disabled &&
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
)}
>
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
@@ -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();
});
});
+109
View File
@@ -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;
}
@@ -439,7 +439,10 @@ function TickerProgressBar({
aria-hidden="true"
>
<motion.span
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
className={cn(
"status-tone-info block h-full rounded-full",
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
)}
animate={{ width: progressLabel }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
@@ -452,9 +455,9 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
const dashOffset = 44 - (44 * progress) / 100;
return (
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
<span className="status-tone-info grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-[var(--status-border)]" />
<motion.circle
cx="8"
cy="8"
@@ -462,13 +465,13 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
fill="none"
strokeWidth="2"
strokeLinecap="round"
className="stroke-blue-600"
className="stroke-[var(--status-foreground)]"
strokeDasharray="44"
animate={{ strokeDashoffset: dashOffset }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
<circle cx="8" cy="8" r="3.5" className="fill-[var(--status-mark)]" />
</svg>
</span>
);
@@ -66,8 +66,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
))}
</div>
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
<section className="material-tone-agent mt-4 rounded border border-violet-100 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-violet-800">
<TriangleAlert size={16} aria-hidden="true" />
Agent 解释
</div>
@@ -7,6 +7,11 @@ import {
showMapNotice
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusDot,
type StatusTone
} from "@/shared/ui/status";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import {
@@ -48,21 +53,13 @@ const FEATURE_LAYER_META: Record<
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
};
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
active: "bg-emerald-50 text-emerald-700 ring-emerald-600/15",
inactive: "bg-slate-100 text-slate-600 ring-slate-500/15",
warning: "bg-orange-50 text-orange-700 ring-orange-600/15",
critical: "bg-red-50 text-red-700 ring-red-600/15",
info: "bg-blue-50 text-blue-700 ring-blue-600/15"
};
const BADGE_DOT_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
active: "bg-emerald-500",
inactive: "bg-slate-400",
warning: "bg-orange-500",
critical: "bg-red-500",
info: "bg-blue-500"
};
const badgeTones = {
active: "success",
inactive: "neutral",
warning: "warning",
critical: "danger",
info: "info"
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
@@ -125,10 +122,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
{panelModel.badge ? (
<span className={cn("inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset", BADGE_CLASS_NAMES[panelModel.badge.tone])}>
<span className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])} aria-hidden="true" />
<StatusBadge
tone={badgeTones[panelModel.badge.tone]}
icon={<StatusDot tone={badgeTones[panelModel.badge.tone]} />}
>
{panelModel.badge.label}
</span>
</StatusBadge>
) : null}
</div>
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
@@ -137,12 +136,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
<Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
<X size={16} aria-hidden="true" />
</button>
</div>
@@ -0,0 +1,104 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { MapDevPanel } from "./map-dev-panel";
afterEach(cleanup);
describe("MapDevPanel flow controls", () => {
it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup();
const commands = createCommands();
render(
<MapDevPanel
commands={commands}
controllerState={createControllerState()}
detailFeature={null}
onClose={vi.fn()}
/>
);
const pipeId = screen.getByLabelText("管道 ID");
const velocity = screen.getByLabelText("有符号流速");
await user.clear(pipeId);
await user.type(pipeId, "P-9");
await user.clear(velocity);
await user.type(velocity, "-2.75");
await user.click(screen.getByRole("button", { name: "更新该管道方向" }));
expect(commands.updatePipeFlowCalculation).toHaveBeenCalledWith({
pipeId: "P-9",
velocity: -2.75
});
});
it("shows the current flow direction counts", () => {
render(
<MapDevPanel
commands={createCommands()}
controllerState={createControllerState({
flowSummary: {
total: 4,
forward: 2,
reverse: 1,
stopped: 1,
invalid: 0
}
})}
detailFeature={null}
onClose={vi.fn()}
/>
);
expect(screen.getByRole("status", {
name: ""
})).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
});
});
function createCommands() {
return {
zoomToFeature: vi.fn(),
locateAndHighlight: vi.fn(),
highlight: vi.fn(),
clearHighlight: vi.fn(),
setFlowVisible: vi.fn(),
replacePipeFlowCalculations: vi.fn(),
updatePipeFlowCalculation: vi.fn(),
clearPipeFlowCalculations: vi.fn(),
showValueLabel: vi.fn(),
clearValueLabel: vi.fn(),
applyLayerGroupStyle: vi.fn(),
resetLayerGroupStyle: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn(),
resetAll: vi.fn()
} satisfies WorkbenchMapCommands;
}
function createControllerState(
patch: Partial<WorkbenchMapControllerState> = {}
): WorkbenchMapControllerState {
return {
target: null,
valueLabels: {},
pending: false,
errorCode: null,
flowVisible: false,
flowSummary: {
total: 0,
forward: 0,
reverse: 0,
stopped: 0,
invalid: 0
},
styledGroups: [],
scadaAnalysis: null,
...patch
};
}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronDown, Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import {
LAYER_GROUP_GEOMETRIES,
@@ -13,7 +14,10 @@ import type {
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, type AvailableWaterNetworkSourceId } from "../map/sources";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
type MapDevPanelProps = {
@@ -28,26 +32,90 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
junctions: "节点",
valves: "阀门",
reservoirs: "水库",
scada: "SCADA"
scada: "SCADA",
pumps: "水泵",
tanks: "水箱"
};
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
const LABEL_PRESETS: Record<
LayerGroupId,
{
property: string;
precision: number;
unit: string;
options: { value: string; label: string }[];
}
> = {
pipes: {
property: "diameter",
precision: 0,
unit: "mm",
options: [
{ value: "diameter", label: "管径 diameter" },
{ value: "length", label: "长度 length" },
{ value: "roughness", label: "粗糙系数 roughness" },
{ value: "minor_loss", label: "局部损失 minor_loss" }
]
},
junctions: {
property: "elevation",
precision: 1,
unit: "m",
options: [
{ value: "elevation", label: "高程 elevation" },
{ value: "base_demand", label: "基础需水量 base_demand" }
]
},
valves: {
property: "diameter",
precision: 0,
unit: "mm",
options: [
{ value: "diameter", label: "管径 diameter" },
{ value: "setting", label: "设定值 setting" },
{ value: "minor_loss", label: "局部损失 minor_loss" }
]
},
reservoirs: {
property: "head",
precision: 1,
unit: "m",
options: [{ value: "head", label: "水头 head" }]
}
};
const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围"
};
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
export function MapDevPanel({
commands,
controllerState,
detailFeature,
onClose
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
const [featureId, setFeatureId] = useState("440681");
const [property, setProperty] = useState("diameter");
const [flowPipeId, setFlowPipeId] = useState("440681");
const [flowVelocity, setFlowVelocity] = useState("-1");
const [precision, setPrecision] = useState(0);
const [unit, setUnit] = useState("mm");
const [groupId, setGroupId] = useState<LayerGroupId>("pipes");
const [showValueLabels, setShowValueLabels] = useState(true);
const [showFeatureIds, setShowFeatureIds] = useState(false);
const [labelSize, setLabelSize] = useState(12);
const [labelColor, setLabelColor] = useState<string>(MAP_STYLE_TOKENS.label.primary);
const [draft, setDraft] = useState<Record<string, string | number>>({
color: MAP_STYLE_TOKENS.supply.pipeMedium,
fillColor: MAP_STYLE_TOKENS.supply.junction,
@@ -68,26 +136,49 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const numericProperties = useMemo(
() => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId
? getNumericFeatureProperties(detailFeature.properties)
: [],
[detailFeature, featureId, sourceId]
);
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
function selectStyleGroup(nextGroupId: LayerGroupId) {
const preset = LABEL_PRESETS[nextGroupId];
const saved = controllerState.valueLabels[nextGroupId];
setGroupId(nextGroupId);
setProperty(saved?.property ?? preset.property);
setPrecision(saved?.precision ?? preset.precision);
setUnit(saved?.unit ?? preset.unit);
setShowValueLabels(saved?.showValue ?? true);
setShowFeatureIds(saved?.showId ?? false);
setLabelSize(saved?.textSize ?? 12);
setLabelColor(saved?.textColor ?? MAP_STYLE_TOKENS.label.primary);
}
function useSelectedFeature() {
if (!detailFeature) return;
if (AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)) {
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId;
selectStyleGroup(nextGroupId);
const allowedProperties = new Set(
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
([name]) => allowedProperties.has(name)
);
if (firstNumeric) setProperty(firstNumeric[0]);
}
}
function applyDraft() {
function applyLayerStyle() {
let patch: LayerGroupStylePatch;
if (geometry === "line") {
patch = {
@@ -103,15 +194,44 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
opacity: Number(draft.opacity),
radiusMultiplier: Number(draft.radiusMultiplier),
strokeMultiplier: Number(draft.strokeMultiplier),
iconMultiplier: Number(draft.iconMultiplier)
};
}
commands.applyLayerGroupStyle(groupId, patch);
}
function applyLabels() {
if (showValueLabels || showFeatureIds) {
void commands.showValueLabel({
sourceId: groupId,
property,
precision,
unit,
showValue: showValueLabels,
showId: showFeatureIds,
textSize: labelSize,
textColor: labelColor
});
} else {
commands.clearValueLabel(groupId);
}
}
function applyFlowCalculation() {
commands.updatePipeFlowCalculation({
pipeId: flowPipeId.trim(),
velocity: Number(flowVelocity)
});
}
const flowSummary = controllerState.flowSummary;
const validFlowCalculation =
flowPipeId.trim().length > 0 && Number.isFinite(Number(flowVelocity));
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
@@ -119,14 +239,23 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p>
</div>
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
<Button
size="icon"
variant="ghost"
type="button"
aria-label="关闭 Dev Panel"
onClick={onClose}
>
<X size={16} />
</button>
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{controllerState.errorCode ? (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
<div
role="alert"
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
@@ -134,101 +263,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
<Field label="业务源">
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as AvailableWaterNetworkSourceId)} className={inputClassName}>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={sourceId}
onChange={(event) => {
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
className={inputClassName}
>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
</Field>
<Field label="Feature ID">
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
<input
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到 Feature
</Button>
</div>
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.target ? (
<StatusText>
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
</StatusText>
) : null}
</PanelSection>
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
<PanelSection
title="全网水流"
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
</PanelSection>
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
<Field label="数值属性">
{numericProperties.length ? (
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
</select>
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
<Button
type="button"
disabled={sourceId !== "pipes" || !target.featureId}
onClick={() => setFlowPipeId(target.featureId)}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后自动显示全网 mock 流向</StatusText>
)}
</PanelSection>
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
>
<Field label="业务图层组">
<div className="relative">
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={groupId}
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
className={cn(inputClassName, "appearance-none pr-8")}
>
{STYLE_GROUP_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
<ChevronDown
size={14}
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
/>
</div>
</Field>
{geometry === "line" ? (
<>
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<Field label="线型">
<select
value={draft.dash}
onChange={(event) =>
setDraft((current) => ({ ...current, dash: event.target.value }))
}
className={inputClassName}
>
<option value="original">原始</option>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
</select>
</Field>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.iconMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<Field label="数值属性">
<select
value={property}
onChange={(event) => setProperty(event.target.value)}
className={inputClassName}
>
{LABEL_PRESETS[groupId].options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-slate-200/70 p-3">
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
<Button
type="button"
onClick={() => commands.resetAll()}
className="w-full text-xs"
variant="outline"
>
<RotateCcw size={14} />
重置全部
</Button>
</div>
</aside>
);
}
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
function PanelSection({
title,
description,
children
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
</div>
{children}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
{children}
</label>
);
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
function ToggleField({
label,
checked,
onChange
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-blue-600"
/>
{label}
</label>
);
}
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
function ColorField({
label,
value,
onChange
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
/>
<span className="text-xs font-medium text-slate-600">{value}</span>
</div>
</Field>
);
}
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
function RangeField({
label,
min,
max,
step,
value,
onChange
}: {
label: string;
min: number;
max: number;
step: number;
value: number;
onChange: (value: number) => void;
}) {
return (
<Field label={`${label} · ${value}`}>
<input
aria-label={label}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-blue-600"
/>
</Field>
);
}
function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
>
{children}
</p>
);
}
const inputClassName =
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
@@ -0,0 +1,17 @@
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
export type MobileWorkbenchSheetSnap = "half" | "full";
export function getMobileWorkbenchSheetHeight(
snap: MobileWorkbenchSheetSnap,
viewportHeight: number
) {
if (snap === "half") {
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
}
return Math.max(
Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio),
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
describe("mobile workbench sheet heights", () => {
it("keeps the two deterministic snap heights", () => {
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
});
it("always leaves the configured map strip visible at full height", () => {
const viewportHeight = 667;
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
expect(viewportHeight - sheetHeight).toBe(68);
});
});
@@ -0,0 +1,198 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode
} from "react";
import { cn } from "@/shared/ui/cn";
import {
getMobileWorkbenchSheetHeight,
type MobileWorkbenchSheetSnap
} from "./mobile-workbench-sheet-layout";
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
type MobileWorkbenchSheetProps = {
label: string;
snap: MobileWorkbenchSheetSnap;
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
onClose: () => void;
children: ReactNode;
className?: string;
};
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
export function MobileWorkbenchSheet({
label,
snap,
onSnapChange,
onClose,
children,
className
}: MobileWorkbenchSheetProps) {
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
const draggedRef = useRef(false);
const [dragHeight, setDragHeight] = useState<number | null>(null);
const [viewportHeight, setViewportHeight] = useState(
typeof window === "undefined" ? 844 : window.innerHeight
);
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
useEffect(() => {
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
window.addEventListener("resize", updateViewportHeight);
window.visualViewport?.addEventListener("resize", updateViewportHeight);
return () => {
window.removeEventListener("resize", updateViewportHeight);
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
};
}, []);
useEffect(() => {
const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
pointerStartRef.current = {
y: event.clientY,
height: restingHeight
};
draggedRef.current = false;
event.currentTarget.setPointerCapture(event.pointerId);
}
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
const start = pointerStartRef.current;
if (!start) {
return;
}
const delta = start.y - event.clientY;
if (Math.abs(delta) > 4) {
draggedRef.current = true;
}
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
}
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
if (!pointerStartRef.current) {
return;
}
const finalHeight = dragHeight ?? pointerStartRef.current.height;
pointerStartRef.current = null;
setDragHeight(null);
event.currentTarget.releasePointerCapture(event.pointerId);
if (!draggedRef.current) {
return;
}
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
}
function handleClick() {
if (draggedRef.current) {
draggedRef.current = false;
return;
}
const currentIndex = SNAP_ORDER.indexOf(snap);
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowUp") {
event.preventDefault();
onSnapChange(getAdjacentSnap(snap, 1));
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
onSnapChange(getAdjacentSnap(snap, -1));
return;
}
if (event.key === "Home") {
event.preventDefault();
onSnapChange("half");
return;
}
if (event.key === "End") {
event.preventDefault();
onSnapChange("full");
}
}
return (
<section
aria-label={label}
data-snap={snap}
className={cn(
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
className
)}
style={{ height: dragHeight ?? restingHeight }}
>
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
<button
type="button"
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
className="group flex h-7 flex-1 touch-none items-center justify-center"
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
</button>
</div>
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
</section>
);
}
function getAdjacentSnap(
current: MobileWorkbenchSheetSnap,
direction: -1 | 1
): MobileWorkbenchSheetSnap {
const currentIndex = SNAP_ORDER.indexOf(current);
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
return SNAP_ORDER[nextIndex] ?? current;
}
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
return SNAP_ORDER.reduce((closest, candidate) => {
const candidateDistance = Math.abs(
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
);
const closestDistance = Math.abs(
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
);
return candidateDistance < closestDistance ? candidate : closest;
}, "half");
}
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
if (snap === "half") {
return "半屏档";
}
return "全屏档";
}
@@ -19,6 +19,11 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import { showMapNotice } from "@/features/map/core";
import {
getRunningWorkflowDefinition,
@@ -37,25 +42,26 @@ import {
formatDuration,
formatTime,
formatTimeRange,
getStatusActivity,
getStatusTone,
getRiskLabel,
getWorkOrderCurrentStage,
getWorkOrderStagePillClassName,
statusClassNames,
getWorkOrderStageActivity,
getWorkOrderStageTone,
statusLabels
} from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000;
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
normal: "border-slate-200 bg-slate-50 text-slate-600",
attention: "border-orange-200 bg-orange-50 text-orange-700",
critical: "border-red-200 bg-red-50 text-red-700"
};
const riskTones = {
normal: "success",
attention: "warning",
critical: "danger"
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
type IncidentOperation = {
target: string;
@@ -86,6 +92,7 @@ type ExecutionStep = {
title: string;
description: string;
status: ExecutionStepStatus;
statusLabel?: string;
meta?: ExecutionStepMeta[];
};
@@ -105,15 +112,21 @@ export function StatusPill({
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
return (
<span
className={cn(
STATUS_PILL_CLASS_NAME,
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
className
)}
<StatusBadge
tone={
isWorkOrder
? getWorkOrderStageTone(currentStage?.id)
: getStatusTone(condition.status)
}
activity={
isWorkOrder
? getWorkOrderStageActivity(currentStage?.id)
: getStatusActivity(condition.status)
}
className={className}
>
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
</span>
</StatusBadge>
);
}
@@ -133,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
<AnimatePresence initial={false} mode="wait">
<motion.div
key={detailKey}
className="min-h-0"
className="relative h-full min-h-0"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
@@ -198,7 +211,7 @@ function ConditionRecordDetailPanel({
<DecisionSummaryBlock
icon={ShieldAlert}
title="异常判断"
iconClassName="text-red-600"
iconClassName="status-tone-danger text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
/>
@@ -221,7 +234,7 @@ function ConditionRecordDetailPanel({
<DecisionSummaryBlock
icon={Lightbulb}
title="关注说明"
iconClassName="text-orange-600"
iconClassName="status-tone-warning text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
/>
@@ -274,7 +287,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
icon={CalendarClock}
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"}
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
/>
@@ -331,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
<section
className={cn(
NEUTRAL_SURFACE_CLASS_NAME,
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
)}
>
<div
data-testid="scheduled-condition-detail-scroll"
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
>
{children}
</div>
</section>
);
}
@@ -343,9 +366,7 @@ type DetailShellProps = {
function DetailShell({ children }: DetailShellProps) {
return (
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
{children}
</div>
<div className="overflow-hidden">{children}</div>
);
}
@@ -520,7 +541,9 @@ function IncidentWorkOrderPanel({
className={cn(
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
"rounded-xl",
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
submitted
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
: "bg-blue-600 text-white hover:bg-blue-700"
)}
>
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
@@ -543,9 +566,9 @@ function IncidentOperationList({
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionEyebrow icon={Wrench} label={operation.action} />
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
<StatusBadge tone={submitted ? "success" : "neutral"}>
{submitted ? "已通知" : operation.elementType}
</span>
</StatusBadge>
</div>
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
<div className="grid grid-cols-[56px_1fr] gap-2">
@@ -645,10 +668,9 @@ function ConditionStatusReport({
function LifecyclePill() {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
<CheckCircle2 size={12} aria-hidden="true" />
<span>完成</span>
</span>
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
完成
</StatusBadge>
);
}
@@ -657,23 +679,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
const label = getRiskLabel(riskLevel);
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
getRiskIndicatorClassName(riskLevel)
)}
<StatusBadge
tone={riskTones[riskLevel]}
icon={<Icon size={12} />}
aria-label={`风险等级:${label}`}
>
<Icon size={12} aria-hidden="true" />
<span>{label}</span>
</span>
{label}
</StatusBadge>
);
}
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
return riskIndicatorClassNames[riskLevel];
}
function ReportMetaTile({ label, value }: { label: string; value: string }) {
return (
<div className={REPORT_META_TILE_CLASS_NAME}>
@@ -709,7 +724,7 @@ function ReportSection({
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
return (
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
@@ -720,9 +735,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
</div>
</div>
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
<StatusBadge tone={riskTones[kpi.status]}>
{getRiskLabel(kpi.status)}
</span>
</StatusBadge>
</div>
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
{kpi.threshold || kpi.baseline ? (
@@ -735,18 +750,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
);
}
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
if (riskLevel === "critical") {
return "border-red-200/80";
}
if (riskLevel === "attention") {
return "border-orange-200/80";
}
return "border-slate-200/70";
}
function DecisionActionStrip({
icon: Icon,
title,
@@ -806,64 +809,36 @@ function SectionEyebrow({
);
}
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
if (tone === "completed") {
return "border-green-100 bg-green-50 text-green-700";
return "success";
}
if (tone === "confirmation") {
return "border-orange-100 bg-orange-50 text-orange-700";
return "warning";
}
if (tone === "active") {
return "border-blue-100 bg-blue-50 text-blue-700";
return "info";
}
return "border-slate-200 bg-slate-50 text-slate-600";
return "neutral";
}
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
if (status === "done") {
return "bg-green-500 text-white";
return "success";
}
if (status === "current") {
return "bg-blue-600 text-white";
return "info";
}
if (status === "confirmation") {
return "bg-orange-500 text-white";
return "warning";
}
return "bg-slate-100 text-slate-400";
}
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
if (status === "current") {
return "surface-reading border-blue-100";
}
if (status === "confirmation") {
return "surface-reading border-orange-100";
}
return "surface-reading border-slate-200/70";
}
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
if (status === "done") {
return "border-green-100 bg-green-50 text-green-700";
}
if (status === "current") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
if (status === "confirmation") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
return "border-slate-200 bg-white text-slate-500";
return "neutral";
}
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
@@ -931,7 +906,6 @@ function RunningWorkflowPanel({
return (
<TaskExecutionPanel
icon={Loader2}
title={workflow.title}
badge="执行中"
badgeTone="active"
@@ -987,7 +961,7 @@ function TaskExecutionPanel({
badgeTone = "pending",
children
}: {
icon: typeof Bot;
icon?: typeof Bot;
title: string;
badge: string;
summary?: string;
@@ -1000,10 +974,17 @@ function TaskExecutionPanel({
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
{Icon ? (
<SectionTitle icon={Icon} title={title} />
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
) : (
<span className="text-xs font-semibold">{title}</span>
)}
<StatusBadge
tone={getTaskExecutionBadgeTone(badgeTone)}
activity={badgeTone === "active" ? "live" : "static"}
>
{badge}
</span>
</StatusBadge>
</div>
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
{meta?.length ? (
@@ -1042,29 +1023,32 @@ function TaskExecutionStepRow({
{!isLast ? (
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
) : null}
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
<SemanticStatusIcon
tone={getTaskExecutionStepTone(step.status)}
activity={step.status === "current" ? "loading" : "static"}
shape="circle"
size="md"
className="relative z-10 ring-4 ring-white"
>
{step.status === "done" ? (
<CheckCircle2 size={14} aria-hidden="true" />
<CheckCircle2 size={14} />
) : step.status === "current" ? (
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
<Loader2 size={14} />
) : step.status === "confirmation" ? (
<AlertTriangle size={14} aria-hidden="true" />
<AlertTriangle size={14} />
) : (
<Clock3 size={14} aria-hidden="true" />
<Clock3 size={14} />
)}
</span>
</SemanticStatusIcon>
</span>
<span
className={cn(
"min-w-0 rounded-xl border px-2.5 py-2",
getTaskExecutionStepSurfaceClassName(step.status)
)}
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"
>
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
{getTaskExecutionStepStatusLabel(step.status)}
</span>
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
</StatusBadge>
</span>
{step.meta?.length ? (
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
@@ -1085,23 +1069,28 @@ function TaskExecutionStepRow({
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const duration = formatDuration(condition.durationMinutes, condition.status);
const currentStage = getWorkOrderCurrentStage(condition);
const steps = condition.stages.map((stage) => ({
const steps = condition.stages.map((stage) => {
const awaitingReply = stage.id === "reply" && stage.status === "current";
return {
id: stage.id,
title: stage.title,
status: stage.status,
status: awaitingReply ? ("confirmation" as const) : stage.status,
statusLabel: awaitingReply ? "待复令" : undefined,
description: stage.description,
meta: [
{ label: "责任人", value: stage.owner },
{ label: "时间", value: stage.timeLabel }
]
}));
};
});
return (
<TaskExecutionPanel
icon={ClipboardList}
title="任务执行"
badge={currentStage ? currentStage.title : condition.code}
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
badgeTone={currentStage?.id === "reply" ? "confirmation" : currentStage?.id === "execution" ? "active" : "pending"}
summary={condition.detail ?? condition.summary}
meta={[
{ label: "来源", value: condition.source },
@@ -1152,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
{index + 1}
</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>
))
) : (
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getStatusActivity,
getStatusTone,
getWorkOrderStageActivity,
getWorkOrderStageTone
} from "./scheduled-condition-feed-utils";
describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => {
expect({
pending: getStatusTone("pending"),
running: getStatusTone("running"),
awaitingReply: getStatusTone("awaiting_reply"),
completed: getStatusTone("completed"),
warning: getStatusTone("warning"),
error: getStatusTone("error")
}).toEqual({
pending: "neutral",
running: "info",
awaitingReply: "warning",
completed: "success",
warning: "warning",
error: "danger"
});
expect(getStatusActivity("running")).toBe("loading");
expect(getStatusActivity("awaiting_reply")).toBe("static");
});
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
expect({
dispatch: getWorkOrderStageTone("dispatch"),
execution: getWorkOrderStageTone("execution"),
reply: getWorkOrderStageTone("reply")
}).toEqual({
dispatch: "neutral",
execution: "info",
reply: "warning"
});
expect(getWorkOrderStageActivity("execution")).toBe("loading");
});
});
@@ -5,51 +5,75 @@ import {
Loader2,
XCircle
} from "lucide-react";
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
import type {
ScheduledConditionItem,
ScheduledConditionStatus,
ScheduledConditionItemStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
export const statusLabels: Record<ScheduledConditionStatus, string> = {
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
warning: "关注",
error: "异常",
pending: "预约"
};
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
completed: "border-green-100 bg-green-50 text-green-700",
running: "border-blue-100 bg-blue-50 text-blue-700",
warning: "border-orange-100 bg-orange-50 text-orange-700",
error: "border-red-100 bg-red-50 text-red-700",
pending: "border-slate-200 bg-slate-100 text-slate-600"
};
const statusTones = {
completed: "success",
running: "info",
awaiting_reply: "warning",
warning: "warning",
error: "danger",
pending: "neutral"
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
const statusActivities = {
completed: "static",
running: "loading",
awaiting_reply: "static",
warning: "static",
error: "static",
pending: "static"
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
awaiting_reply: Clock3,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
};
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
completed: "bg-green-50 text-green-700",
running: "bg-blue-50 text-blue-700",
warning: "bg-orange-50 text-orange-700",
error: "bg-red-50 text-red-700",
pending: "bg-slate-100 text-slate-600"
};
const workOrderStageTones = {
dispatch: "neutral",
execution: "info",
reply: "warning"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
const workOrderStageActivities = {
dispatch: "static",
execution: "loading",
reply: "static"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"running",
"awaiting_reply",
"warning",
"error",
"completed",
"pending"
];
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
export type ConditionTaskFilterOption = {
value: ConditionTaskFilterValue;
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
};
export type ConditionStatusFilterOption = {
value: ScheduledConditionStatus;
value: ScheduledConditionItemStatus;
label: string;
count: number;
};
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
}
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
const statusCounts = new Map<ScheduledConditionStatus, number>();
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
conditions.forEach((condition) => {
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
if (status === "running") {
return "执行中";
}
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
);
}
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
if (stageId === "execution") {
return "border-blue-100 bg-blue-50 text-blue-700";
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageTones[stageId] : "neutral";
}
if (stageId === "reply") {
return "border-green-100 bg-green-50 text-green-700";
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageActivities[stageId] : "static";
}
return "border-indigo-100 bg-indigo-50 text-indigo-700";
}
export function getStatusIcon(status: ScheduledConditionStatus) {
export function getStatusIcon(status: ScheduledConditionItemStatus) {
return statusIcons[status];
}
export function getStatusIconClassName(status: ScheduledConditionStatus) {
return statusIconClassNames[status];
export function getStatusTone(status: ScheduledConditionItemStatus) {
return statusTones[status];
}
export function getStatusActivity(status: ScheduledConditionItemStatus) {
return statusActivities[status];
}
@@ -4,6 +4,7 @@ import {
ChevronRight,
ClipboardList,
History,
X,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
@@ -20,6 +21,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
@@ -27,8 +29,12 @@ import {
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusActivity,
getStatusIcon,
getStatusIconClassName,
getStatusTone,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
@@ -36,10 +42,18 @@ import {
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
"surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
"surface-control rounded-xl border border-slate-200/70";
export type ScheduledConditionFeedPresentation =
| "desktop-dock"
| "desktop-floating"
| "mobile-sheet";
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
@@ -50,6 +64,7 @@ type ScheduledConditionFeedProps = {
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
@@ -60,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
};
export function ScheduledConditionFeed({
presentation = "desktop-dock",
conditions = [],
expanded: controlledExpanded,
focusRequest,
@@ -70,13 +86,21 @@ export function ScheduledConditionFeed({
onFocusRequestHandled,
onSelectedConditionChange,
onExpandAgent,
onClose,
onLoadHistorySession,
onSubmitPrompt
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
string | null
>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
@@ -89,10 +113,16 @@ export function ScheduledConditionFeed({
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilteredConditions = useMemo(
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
() =>
conditions.filter(
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
),
[conditions, taskFilter]
);
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
const statusFilterOptions = useMemo(
() => createStatusFilterOptions(taskFilteredConditions),
[taskFilteredConditions]
);
const taskFilteredConditionCount = taskFilteredConditions.length;
const filteredConditions = useMemo(
() =>
@@ -101,7 +131,10 @@ export function ScheduledConditionFeed({
),
[statusFilter, taskFilteredConditions]
);
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
const { recentConditions, futureWorkOrders } = useMemo(
() => groupScheduledConditions(filteredConditions),
[filteredConditions]
);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
@@ -170,7 +203,9 @@ export function ScheduledConditionFeed({
return;
}
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
const targetCondition = allTimelineConditions.find(
(condition) => condition.id === focusConditionId
);
if (!targetCondition) {
return;
}
@@ -180,11 +215,20 @@ export function ScheduledConditionFeed({
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
}, [
allTimelineConditions,
focusConditionId,
focusRequestId,
onFocusRequestHandled,
updateExpanded,
updateSelectedConditionId
]);
function handleToggleExpanded() {
updateExpanded(!expanded);
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
updateSelectedConditionId(
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
);
}
function handleSelectTimelineCondition(conditionId: string) {
@@ -226,7 +270,8 @@ export function ScheduledConditionFeed({
id: "scheduled-condition-generated-session",
tone: "error",
title: "工况对话发起失败",
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
message:
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
});
});
return;
@@ -240,15 +285,39 @@ export function ScheduledConditionFeed({
aria-label="工况任务"
aria-hidden={!visible}
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
mobileSheet
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
: cn(
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl"
"max-w-[calc(100vw-7rem)]",
desktopFloating
? cn(
"rounded-2xl",
expanded
? "h-[calc(100dvh-8rem)]"
: "max-h-[calc(100dvh-8rem)]"
)
: "h-full rounded-l-2xl rounded-r-none",
expanded && !desktopDock
? "scheduled-feed-panel-expanded flex flex-col"
: "w-[var(--workbench-condition-width)]"
)
)}
>
<header
className={cn(
"flex items-center gap-2.5 px-2.5 py-2",
CONDITION_CONTROL_SURFACE_CLASS_NAME
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
"rounded-lg"
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
<CalendarClock size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
@@ -271,18 +340,39 @@ export function ScheduledConditionFeed({
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button>
{mobileSheet && onClose ? (
<button
type="button"
aria-label="关闭工况任务"
title="关闭工况任务"
onClick={onClose}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<X size={17} aria-hidden="true" />
</button>
) : null}
</header>
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
compactPresentation
? "flex flex-1"
: expanded
? "scheduled-feed-layout-expanded flex-1"
: "scheduled-feed-layout-collapsed"
)}
>
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
"relative min-h-0 min-w-0 overflow-hidden",
compactPresentation
? expanded
? "scheduled-feed-detail-enter h-full w-full"
: "hidden"
: expanded
? "scheduled-feed-detail-enter h-full"
: "pointer-events-none opacity-0"
)}
aria-hidden={!expanded}
>
@@ -295,9 +385,15 @@ export function ScheduledConditionFeed({
/>
) : null}
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
compactPresentation && (expanded ? "hidden" : "h-full w-full")
)}
>
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
@@ -305,7 +401,7 @@ export function ScheduledConditionFeed({
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
filteredConditionCount={filteredConditionCount}
@@ -325,6 +421,7 @@ export function ScheduledConditionFeed({
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
@@ -344,6 +441,7 @@ type ConditionTimelinePanelProps = {
function ConditionTimelinePanel({
expanded,
presentation,
loading,
recentConditions,
futureWorkOrders,
@@ -365,9 +463,14 @@ function ConditionTimelinePanel({
return (
<section
aria-busy={loading}
data-testid="scheduled-condition-timeline"
className={cn(
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
presentation === "mobile-sheet"
? "h-full max-h-none"
: expanded
? "h-full max-h-none"
: "h-[420px] max-h-[420px]",
CONDITION_CONTENT_SURFACE_CLASS_NAME
)}
>
@@ -385,10 +488,15 @@ function ConditionTimelinePanel({
onResetFilters={onResetFilters}
/>
) : null}
{expanded && !loading && futureWorkOrders.length > 0 ? (
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
<section className="mb-2">
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
<ConditionGroupHeader
icon={ClipboardList}
title="预约任务"
count={futureWorkOrders.length}
compact
/>
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
<ConditionRows
conditions={futureWorkOrders}
selectedConditionId={selectedConditionId}
@@ -397,7 +505,11 @@ function ConditionTimelinePanel({
</div>
</section>
) : null}
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
<ConditionGroupHeader
icon={History}
title="最近工况"
count={loading ? 0 : recentConditions.length}
/>
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
@@ -410,7 +522,11 @@ function ConditionTimelinePanel({
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
}
/>
)}
</div>
@@ -446,7 +562,7 @@ function ConditionFilterBar({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700">筛选</span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
@@ -477,7 +593,7 @@ function ConditionFilterBar({
<button
type="button"
onClick={onResetFilters}
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
>
<XCircle size={12} aria-hidden="true" />
重置筛选
@@ -519,10 +635,15 @@ function ConditionFilterDropdown<TValue extends string>({
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
<span
className="block truncate font-semibold text-slate-800"
title={selectedOption?.label}
>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
<span className="block text-xs leading-3 text-slate-400">
{selectedOption?.count ?? 0} 条
</span>
</span>
<ChevronDown
size={13}
@@ -536,7 +657,10 @@ function ConditionFilterDropdown<TValue extends string>({
sideOffset={6}
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
@@ -582,7 +706,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
aria-hidden="true"
/>
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
@@ -605,10 +732,7 @@ function ConditionDetailSkeleton() {
return (
<DetailScroll>
<div
className={cn(
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
"rounded-xl"
)}
className="relative min-h-[420px] overflow-hidden px-4 py-3"
role="status"
aria-label="工况详情加载中"
>
@@ -626,7 +750,10 @@ function ConditionDetailSkeleton() {
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
<div
key={index}
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
>
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
@@ -640,8 +767,18 @@ function ConditionDetailSkeleton() {
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
<section
className={cn(
CONDITION_CONTENT_SURFACE_CLASS_NAME,
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
)}
>
<div
data-testid="scheduled-condition-detail-scroll"
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
>
{children}
</div>
</section>
);
}
@@ -653,9 +790,19 @@ type ConditionGroupHeaderProps = {
compact?: boolean;
};
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
function ConditionGroupHeader({
icon: Icon,
title,
count,
compact = false
}: ConditionGroupHeaderProps) {
return (
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
<div
className={cn(
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
compact ? "mb-1" : "mb-2"
)}
>
<span className="flex items-center gap-1.5">
<Icon size={13} aria-hidden="true" />
<span>{title}</span>
@@ -694,9 +841,21 @@ type ConditionTimelineRowProps = {
onSelect: () => void;
};
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
const StatusIcon = getStatusIcon(condition.status);
function ConditionTimelineRow({
condition,
selected,
isLast,
onSelect
}: ConditionTimelineRowProps) {
const Icon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
const tone = isWorkOrder
? getWorkOrderStageTone(workOrderStage)
: getStatusTone(condition.status);
const activity = isWorkOrder
? getWorkOrderStageActivity(workOrderStage)
: getStatusActivity(condition.status);
return (
<button
@@ -705,44 +864,48 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
selected
? "surface-reading border-blue-100 text-blue-900"
: "border-transparent hover:bg-slate-50"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
<span
className="relative flex min-h-12 justify-center pt-1.5"
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
{formatTime(condition.scheduledAt)}
</span>
<span className="relative flex min-h-12 justify-center pt-1.5">
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
aria-hidden="true"
/>
) : null}
<span
<SemanticStatusIcon
tone={tone}
activity={activity}
shape="circle"
data-selected={selected ? "true" : undefined}
className={cn(
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
selected
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
: isWorkOrder
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
: getStatusIconClassName(condition.status)
"relative z-10 ring-4 ring-white",
selected && "group-focus-visible:ring-blue-100"
)}
>
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
</span>
<Icon size={12} />
</SemanticStatusIcon>
</span>
<span
className={cn(
"min-w-0 px-2 py-1.5 transition-colors",
selected
? "text-blue-900"
: "text-slate-700"
selected ? "text-blue-900" : "text-slate-700"
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
<span className="block truncate text-xs font-semibold leading-4">
{condition.title}
</span>
<span className="block truncate text-xs leading-4 text-slate-500">
{condition.summary}
</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
</span>
@@ -1,12 +1,6 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { ComponentProps } from "react";
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
import { useEffect, type ComponentProps } from "react";
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -14,105 +8,52 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
mobileOpen: boolean;
mobilePanelCollapsing: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
onCollapsePanel: () => void;
onCloseMobilePanel: () => void;
onExpandPanel: () => void;
onOpenMobilePanel: () => void;
onWidthCommit?: (width: number) => void;
};
export function WorkbenchAgentPanels({
panelProps,
panelOpen,
panelCollapsing,
mobileOpen,
mobilePanelCollapsing,
personaState,
statusLabel,
viewportWidth,
onCollapsePanel,
onCloseMobilePanel,
onExpandPanel,
onOpenMobilePanel
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth);
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
useEffect(() => {
onWidthCommit?.(committedWidth);
}, [committedWidth, onWidthCommit]);
return (
<>
<div
ref={resize.panelRef}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
style={resize.width === null ? undefined : { width: resize.width }}
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
style={{ width: panelOpen ? committedWidth : 0 }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
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
personaState={personaState}
statusLabel={statusLabel}
onExpand={onExpandPanel}
/>
</div>
)}
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...panelProps}
collapsing={mobilePanelCollapsing}
onCollapse={onCloseMobilePanel}
/>
</div>
) : null}
</div>
{!mobileOpen ? (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpenMobilePanel}
className={cn(
"grid h-12 w-12 place-items-center text-violet-700",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
</button>
</div>
) : null}
</>
);
}
@@ -24,6 +24,12 @@ import {
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +42,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
compactHeaderTextClassName,
headerControlButtonClassName,
headerControlIconClassName
@@ -47,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
review: "待复核"
};
const scenarioStatusTones = {
active: "info",
draft: "neutral",
review: "warning"
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
@@ -112,9 +125,9 @@ export function ScenarioMenu({
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
{scenarioStatusLabels[scenario.status]}
</span>
</StatusBadge>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
@@ -161,29 +174,38 @@ export function AlertMenu({
className={cn(
"group relative px-2",
headerControlButtonClassName,
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
compact && "h-10 w-10 justify-center px-0"
hasAlerts && !compact &&
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
>
<span
className={cn(
headerControlIconClassName,
hasAlerts &&
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
)}
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
{compact ? (
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
className={cn(
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
!hasAlerts && "border-transparent! bg-transparent!"
)}
>
{alerts.length}
</span>
</StatusBadge>
) : (
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
>
{alerts.length}
</span>
</StatusBadge>
)}
</button>
</DropdownMenuTrigger>
@@ -199,7 +221,7 @@ export function AlertMenu({
<DropdownMenuItem
key={alert.id}
className={cn(
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
@@ -207,20 +229,22 @@ export function AlertMenu({
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
<StatusDot tone="danger" size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} />
</SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
</div>
@@ -259,7 +283,8 @@ export function UserMenu({
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -268,13 +293,18 @@ export function UserMenu({
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group px-2", headerControlButtonClassName)}
className={cn(
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
headerControlButtonClassName,
compactHeaderControlButtonClassName
)}
aria-label="打开用户菜单"
>
<span className={headerControlIconClassName}>
@@ -292,7 +322,15 @@ export function UserMenu({
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<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" />
退出登录
</DropdownMenuItem>
@@ -316,15 +354,13 @@ function AlertQueueSummary({
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
)}
<SemanticStatusIcon
tone={hasAlerts ? "danger" : "success"}
size="md"
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
</span>
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
</SemanticStatusIcon>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-500">
@@ -332,17 +368,12 @@ function AlertQueueSummary({
</p>
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
)}
>
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
{hasAlerts ? "需复核" : "正常"}
</span>
</StatusBadge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
</div>
</div>
@@ -427,9 +458,9 @@ function MenuHeader({
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
</div>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
</div>
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
</div>
);
}
@@ -469,7 +500,7 @@ function MenuAction({
"grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
)}
>
@@ -1,7 +1,16 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName =
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
cn(
buttonInteractionVariants({ tone: "neutral" }),
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
);
export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0";
export const headerControlIconClassName =
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
export const compactHeaderTextClassName = "hidden 2xl:inline";
@@ -7,11 +7,10 @@ import {
Eye,
EyeOff,
FlaskConical,
Sparkles,
type LucideIcon
} 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";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
@@ -29,6 +28,7 @@ export type WorkbenchTopBarProps = {
alerts: WorkbenchAlert[];
user: WorkbenchUser;
conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
devPanelEnabled: boolean;
@@ -36,16 +36,19 @@ export type WorkbenchTopBarProps = {
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
onToggleDevPanel: () => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
@@ -58,6 +61,7 @@ export function WorkbenchTopBar({
alerts,
user,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
taskTickerVisible,
devPanelEnabled,
@@ -65,19 +69,23 @@ export function WorkbenchTopBar({
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
onToggleDevPanel,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport,
onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const activeScenario =
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null);
};
@@ -85,11 +93,18 @@ export function WorkbenchTopBar({
return (
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<div
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)}
>
<Droplets size={18} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
供水管网智能调度系统
</h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
</div>
</div>
@@ -132,19 +147,47 @@ export function WorkbenchTopBar({
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{devPanelEnabled ? <div className="hidden lg:block">
<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 ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="切换地图 Dev Panel"
aria-pressed={devPanelOpen}
data-selection-tone="soft"
onClick={onToggleDevPanel}
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
<span className={headerControlIconClassName}>
<FlaskConical size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>Dev</span>
</button>
</div> : null}
</div>
) : null}
<div className="lg:hidden">
<AlertMenu
compact
@@ -165,6 +208,7 @@ export function WorkbenchTopBar({
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
onLogout={onLogout}
/>
</div>
</header>
@@ -191,20 +235,12 @@ function TaskTickerToggle({
type="button"
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
aria-pressed={visible}
data-selection-tone="soft"
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Icon size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>任务浮条</span>
@@ -212,30 +248,18 @@ function TaskTickerToggle({
);
}
function ConditionFeedToggle({
visible,
onToggle
}: {
visible: boolean;
onToggle: () => void;
}) {
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
return (
<button
type="button"
aria-label="工况任务"
aria-pressed={visible}
data-selection-tone="soft"
title="工况任务"
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<CalendarClock size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>工况任务</span>
@@ -243,12 +267,49 @@ function ConditionFeedToggle({
);
}
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
function TimelineToggle({
visible,
compact = false,
onToggle
}: {
visible: boolean;
compact?: boolean;
onToggle: () => void;
}) {
return (
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<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({
icon: Icon,
label,
value
}: {
icon?: LucideIcon;
label: string;
value: string;
}) {
return (
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
{value}
</span>
</div>
);
}
@@ -28,4 +28,37 @@ describe("createOperationalScheduledConditions", () => {
expect(currentCondition?.status).toBe("running");
expect(currentCondition?.durationMinutes).toBe(5);
});
it("labels the active work-order stage as executing", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const executingWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
)
);
expect(
executingWorkOrder?.kind === "work_order"
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
: undefined
).toBe("执行中");
});
it("moves completed field work into a waiting-for-reply state", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const awaitingReplyWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
);
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
expect(
awaitingReplyWorkOrder?.kind === "work_order"
? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title
: undefined
).toBe("待复令");
});
});
@@ -5,7 +5,8 @@ import type {
ScheduledConditionReport,
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId
ScheduledConditionTaskId,
ScheduledWorkOrderStatus
} from "../types";
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
@@ -16,6 +17,7 @@ const MODEL_NAME = "DrainFlow Realtime Simulator";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId;
type WorkOrderPhase = "dispatch" | "execution" | "reply";
type ConditionTaskDefinition = {
id: ConditionTaskId;
@@ -332,6 +334,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
return {
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
@@ -340,7 +343,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
title: workOrder.title,
summary: workOrder.summary,
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
status: getWorkOrderStatus(phase),
riskLevel: workOrder.riskLevel,
updatedAt: now.getTime(),
detail: workOrder.detail,
@@ -352,7 +355,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
assignee: workOrder.assignee,
priority: workOrder.priority,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
replyRequirements:
currentMinutesOfDay > replyDeadlineMinute
? []
@@ -385,11 +388,38 @@ function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMin
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
}
function getWorkOrderPhase(
workOrder: ManualWorkOrderDefinition,
currentMinutesOfDay: number
): WorkOrderPhase {
if (currentMinutesOfDay < workOrder.startMinute) {
return "dispatch";
}
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
return "execution";
}
return "reply";
}
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
if (phase === "dispatch") {
return "pending";
}
if (phase === "execution") {
return "running";
}
return "awaiting_reply";
}
function createWorkOrderStages(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
currentMinutesOfDay: number
phase: WorkOrderPhase
) {
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
@@ -397,37 +427,32 @@ function createWorkOrderStages(
now,
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
);
const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes;
return [
{
id: "dispatch" as const,
title: "派发",
status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const),
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution" as const,
title: "执行",
title: "执行中",
status:
currentMinutesOfDay >= executionEndMinute
? ("done" as const)
: currentMinutesOfDay >= workOrder.startMinute
phase === "dispatch"
? ("pending" as const)
: phase === "execution"
? ("current" as const)
: ("pending" as const),
: ("done" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
description: workOrder.summary
},
{
id: "reply" as const,
title: "复令",
status:
currentMinutesOfDay >= executionEndMinute
? ("current" as const)
: ("pending" as const),
title: "待复令",
status: phase === "reply" ? ("current" as const) : ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
@@ -9,27 +9,44 @@ import {
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchViewportLayout
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number) {
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const widthRef = useRef<number | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
setWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
if (widthRef.current === null) {
return;
}
const resize = useCallback((clientX: number) => {
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
}, []);
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
widthRef.current = nextWidth;
setWidth(nextWidth);
setCommittedWidth(nextWidth);
}, [conditionExpanded, viewportWidth]);
const resize = useCallback(
(clientX: number) => {
const nextWidth = clampAgentPanelWidth(
clientX - resizeLeftRef.current,
window.innerWidth,
conditionExpanded
);
widthRef.current = nextWidth;
setWidth(nextWidth);
},
[conditionExpanded]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -48,6 +65,7 @@ export function useAgentPanelResize(viewportWidth: number) {
const stopResizing = () => {
cleanupRef.current?.();
setResizing(false);
setCommittedWidth(widthRef.current);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
@@ -64,7 +82,8 @@ export function useAgentPanelResize(viewportWidth: number) {
[resize]
);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
@@ -74,18 +93,24 @@ export function useAgentPanelResize(viewportWidth: number) {
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
} else if (event.key === "End") {
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
} else {
return;
}
event.preventDefault();
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
widthRef.current = clampedWidth;
setWidth(clampedWidth);
setCommittedWidth(clampedWidth);
},
[conditionExpanded]
);
return {
committedWidth,
handleKeyDown,
handlePointerDown,
panelRef,
@@ -7,7 +7,7 @@ import {
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
describe("map feature interaction state", () => {
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
it("maps selected business features to promoted vector feature references", () => {
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
@@ -18,7 +18,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
expect(map.setFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
{ selected: true, hovered: false }
);
});
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
clearMapFeatureInteractionState(map, feature, "selected");
expect(map.removeFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
"selected"
);
});
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit"
]);
});
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
source: WaterNetworkSourceId;
sourceLayer: string;
sourceLayer?: string;
id: string;
hovered: boolean;
selected: boolean;
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
feature: Pick<DetailFeature, "id" | "layer"> | null
): FeatureReference | null {
if (!feature?.id) return null;
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
return feature.layer === "scada"
? { source: feature.layer, id: feature.id }
: { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
}
export function setMapFeatureInteractionState(
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
feature: FeatureReference,
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
) {
map.setFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
state
);
map.setFeatureState(toFeatureStateTarget(feature), state);
}
export function clearMapFeatureInteractionState(
@@ -44,10 +43,7 @@ export function clearMapFeatureInteractionState(
feature: FeatureReference,
key?: "hovered" | "selected"
) {
map.removeFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
key
);
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type UseMapInteractionsOptions = {
@@ -81,7 +77,9 @@ export function useMapInteractions({
const id = feature && getFeatureId(feature);
if (!source || !id) return;
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
const next = source === "scada"
? { source, id }
: { source, sourceLayer: SOURCE_LAYERS[source], id };
if (selectedFeature?.id === id && selectedFeature.layer === source) {
clearHoveredFeature();
return;
@@ -127,3 +125,9 @@ export function useMapInteractions({
};
}, [mapReady, mapRef, selectedFeature]);
}
function toFeatureStateTarget(feature: FeatureReference) {
return feature.sourceLayer
? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }
: { source: feature.source, id: feature.id };
}
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import {
@@ -13,10 +14,12 @@ import {
createAgentApiClient,
fetchAgentBootstrap,
fetchAgentSessions,
hasPendingAgentInput,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentConnectionStatus,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
@@ -45,25 +48,28 @@ import {
const AGENT_PANEL_COLLAPSE_MS = 180;
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
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 mobileCollapseTimerRef = useRef<number | 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 approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true);
const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
@@ -71,7 +77,8 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
const [connectionStatus, setConnectionStatus] =
useState<AgentConnectionStatus>("connecting");
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
@@ -140,6 +147,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const handleDataPart = useCallback(
(part: AgentDataPart) => {
setConnectionStatus("online");
const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return;
@@ -203,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
() =>
new DefaultChatTransport<AgentUiMessage>({
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 }) {
return {
body: {
@@ -218,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
};
}
}),
[]
[getAccessToken]
);
const chat = useChat<AgentUiMessage>({
@@ -226,7 +238,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
onData: (part) => handleDataPart(part as AgentDataPart),
onError: (error) => {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setRuntimeAvailability("failed");
setConnectionStatus("offline");
setStatusLabel("Agent 后端请求失败");
showMapNotice({
id: "agent-stream-status",
@@ -237,6 +249,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
},
onFinish: () => {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setConnectionStatus("online");
setStatusLabel("Agent 分析完成");
}
});
@@ -303,7 +316,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
if (chatRef.current.status === "ready") {
setRuntimeAvailability("connected");
setConnectionStatus("online");
setStatusLabel("准备就绪");
}
}, [backendConnection]);
@@ -313,7 +326,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
return;
}
setRuntimeAvailability("unavailable");
setConnectionStatus("offline");
setStatusLabel("Agent 后端不可用");
}, [backendConnectionError]);
@@ -371,10 +384,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const personaState = useMemo(
() => deriveAgentPersonaState(messages, {
chatStatus: chat.status,
runtimeAvailability,
statusLabel
connectionStatus
}),
[chat.status, messages, runtimeAvailability, statusLabel]
[chat.status, connectionStatus, messages]
);
const refreshSessionHistory = useCallback(async () => {
@@ -478,7 +490,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
})
.catch((error) => {
if (!controller.signal.aborted) {
setRuntimeAvailability("failed");
setConnectionStatus("offline");
setStatusLabel("Agent 会话流恢复失败");
showMapNotice({
id: "agent-stream-status",
@@ -589,7 +601,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
clearSessionId();
setSessionTitle("新对话");
setStatusLabel(nextStatusLabel);
setRuntimeAvailability("connected");
setConnectionStatus("online");
setPermissionOverrides({});
setQuestionOverrides({});
setStreamRenderState({});
@@ -793,6 +805,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
modelOptions,
openMobilePanel,
closeMobilePanel,
connectionStatus,
panelCollapsing,
panelOpen,
personaState,
@@ -824,23 +837,17 @@ function deriveAgentPersonaState(
messages: AgentChatMessage[],
{
chatStatus,
runtimeAvailability,
statusLabel
connectionStatus
}: {
chatStatus: string;
runtimeAvailability: AgentRuntimeAvailability;
statusLabel: string;
connectionStatus: AgentConnectionStatus;
}
): PersonaState {
if (
runtimeAvailability === "unavailable" ||
runtimeAvailability === "failed" ||
/失败|不可用|错误|降级/.test(statusLabel)
) {
if (connectionStatus === "offline") {
return "asleep";
}
if (hasPendingOperatorInput(messages)) {
if (hasPendingAgentInput(messages)) {
return "listening";
}
@@ -848,7 +855,7 @@ function deriveAgentPersonaState(
chatStatus === "submitted" ||
chatStatus === "streaming" ||
hasRunningSessionWork(messages) ||
runtimeAvailability === "connecting"
connectionStatus === "connecting"
) {
return "thinking";
}
@@ -856,14 +863,6 @@ function deriveAgentPersonaState(
return "idle";
}
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
return messages.some(
(message) =>
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
);
}
function hasRunningSessionWork(messages: AgentChatMessage[]) {
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
@@ -1,4 +1,6 @@
import type { FeatureCollection, Point } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { ScadaDevice } from "../api/scada-client";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
import { WorkbenchMapController } from "../map/workbench-map-controller";
@@ -7,15 +9,33 @@ export function useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
rightPanelOpen,
conditionPanelExpanded = false,
agentPanelWidth,
getScadaFeatures
}: {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
conditionPanelExpanded?: boolean;
agentPanelWidth?: number;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
}) {
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
const valuesRef = useRef({
mapReady,
leftPanelOpen,
rightPanelOpen,
conditionPanelExpanded,
agentPanelWidth
});
valuesRef.current = {
mapReady,
leftPanelOpen,
rightPanelOpen,
conditionPanelExpanded,
agentPanelWidth
};
const controllerRef = useRef<WorkbenchMapController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new WorkbenchMapController({
@@ -24,14 +44,25 @@ export function useWorkbenchMapController({
getPadding: () => {
const map = mapRef.current;
return map
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
? getResponsiveWorkbenchPadding(
map,
valuesRef.current.leftPanelOpen,
valuesRef.current.rightPanelOpen,
valuesRef.current.conditionPanelExpanded,
valuesRef.current.agentPanelWidth
)
: { top: 48, right: 48, bottom: 48, left: 48 };
}
},
getScadaFeatures
});
}
const controller = controllerRef.current;
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
const state = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot
);
useEffect(() => () => controller.destroy(), [controller]);
return { controller, state };
}
+141 -51
View File
@@ -1,8 +1,11 @@
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { FeatureCollection, Point } from "geojson";
import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
@@ -16,19 +19,17 @@ import {
waterNetworkInteractionLayers
} from "../map/layers";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import {
VALUE_LABEL_LAYER_IDS,
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection
} from "../map/value-label";
import { createValueLabelLayers } from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
createBaseStyle,
createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW
} from "../map/sources";
import {
SCADA_SOURCE_ID,
registerScadaImages,
scadaBusinessLayers,
scadaFallbackBusinessLayers,
@@ -47,11 +48,10 @@ declare global {
type UseWorkbenchMapOptions = {
containerRef: RefObject<HTMLDivElement | null>;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
getAccessToken?: AccessTokenProvider;
};
type UseWorkbenchMapResult = {
@@ -59,6 +59,7 @@ type UseWorkbenchMapResult = {
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
fitNetworkBounds: () => void;
};
@@ -74,6 +75,7 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"geoserver-mvt": "GeoServer MVT",
"scada-api": "SCADA API",
"scada-icons": "SCADA 图标",
"annotation-source": "业务标注源"
};
@@ -81,29 +83,43 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])),
...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
};
export function useWorkbenchMap({
containerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature,
selectedFeature
selectedFeature,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
type: "FeatureCollection",
features: []
});
const scadaLoadPromiseRef = useRef<Promise<FeatureCollection<Point, ScadaDevice>>>(
Promise.resolve(scadaCollectionRef.current)
);
useEffect(() => {
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
}, [leftPanelOpen, rightPanelOpen]);
const getScadaFeatures = useCallback(async (deviceIds?: string[], signal?: AbortSignal) => {
const collection = await scadaLoadPromiseRef.current;
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
if (!deviceIds) return collection;
const selectedIds = new Set(deviceIds);
return {
type: "FeatureCollection" as const,
features: collection.features.filter((feature) => selectedIds.has(String(feature.id)))
};
}, []);
useEffect(() => {
impactVisibleRef.current = impactVisible;
@@ -115,6 +131,13 @@ export function useWorkbenchMap({
}
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const scadaLoadPromise = fetchScadaDeviceCollection(
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = scadaLoadPromise;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
@@ -129,39 +152,35 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources();
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]));
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
map.addSource(sourceId, sources[sourceId])
);
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers = scadaRegistration.status === "unavailable"
const activeScadaBusinessLayers =
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
: scadaBusinessLayers;
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[0],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["==", ["geometry-type"], "Point"],
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[1],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type === "symbol")
.forEach((layer) => map.addLayer(layer));
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
setSimulationLayersVisibility(map, impactVisibleRef.current);
@@ -169,7 +188,11 @@ export function useWorkbenchMap({
updateSourceStatus(
setSourceStatuses,
"scada-icons",
scadaRegistration.status === "ready" ? "online" : scadaRegistration.status === "degraded" ? "degraded" : "offline",
scadaRegistration.status === "ready"
? "online"
: scadaRegistration.status === "degraded"
? "degraded"
: "offline",
scadaRegistration.status === "ready"
? "SCADA 图标已就绪。"
: scadaRegistration.status === "degraded"
@@ -180,11 +203,38 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
});
void scadaLoadPromise.then((collection) => {
scadaCollectionRef.current = collection;
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
"online",
`已通过API加载 ${collection.features.length} 个SCADA设备。`
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => {
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = null;
resizeMap();
});
});
resizeObserver.observe(containerRef.current);
window.addEventListener("resize", resizeMap);
window.setTimeout(resizeMap, 0);
window.setTimeout(resizeMap, 300);
window.setTimeout(resizeMap, 1000);
map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
@@ -199,7 +249,12 @@ export function useWorkbenchMap({
if (!message.includes("AbortError")) {
const sourceGroupId = getSourceGroupFromErrorEvent(event);
if (sourceGroupId) {
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
updateSourceStatus(
setSourceStatuses,
sourceGroupId,
"offline",
getSourceErrorMessage(sourceGroupId, message)
);
} else {
setMapError(message);
}
@@ -210,11 +265,16 @@ export function useWorkbenchMap({
return () => {
window.removeEventListener("resize", resizeMap);
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
scadaAbortController.abort();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef]);
}, [containerRef, getAccessToken]);
useMapInteractions({
mapRef,
@@ -241,6 +301,7 @@ export function useWorkbenchMap({
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
getScadaFeatures,
fitNetworkBounds: fitToNetworkBounds
};
}
@@ -248,7 +309,11 @@ export function useWorkbenchMap({
function updateStatusFromSourceEvent(
event: MapSourceDataEvent,
status: WorkbenchSourceStatusValue,
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
setSourceStatuses: (
updater: (
current: Record<string, WorkbenchSourceStatus>
) => Record<string, WorkbenchSourceStatus>
) => void
) {
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
if (!sourceGroupId) {
@@ -259,11 +324,20 @@ function updateStatusFromSourceEvent(
return;
}
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
updateSourceStatus(
setSourceStatuses,
sourceGroupId,
status,
getSourceStatusMessage(sourceGroupId, status)
);
}
function updateSourceStatus(
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
setSourceStatuses: (
updater: (
current: Record<string, WorkbenchSourceStatus>
) => Record<string, WorkbenchSourceStatus>
) => void,
sourceGroupId: string,
status: WorkbenchSourceStatusValue,
message: string
@@ -305,15 +379,27 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") {
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
return status === "online"
? "底图瓦片已恢复正常。"
: "底图瓦片请求中断,业务图层仍可继续使用。";
}
if (sourceGroupId === "geoserver-mvt") {
return status === "online" ? "供水管网矢量瓦片已加载。" : "供水管网瓦片请求中断,当前视图可能不完整。";
return status === "online"
? "供水管网矢量瓦片已加载。"
: "供水管网瓦片请求中断,当前视图可能不完整。";
}
if (sourceGroupId === "annotation-source") {
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
return status === "online"
? "业务标注和影响范围已加载。"
: "业务标注源加载中断,模拟标注可能暂不可见。";
}
if (sourceGroupId === "scada-api") {
return status === "online"
? "SCADA设备已通过API加载。"
: "SCADA API请求中断,管网图层仍可继续使用。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
@@ -332,5 +418,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
return `业务标注源请求失败:${errorMessage}`;
}
if (sourceGroupId === "scada-api") {
return `SCADA API请求失败:${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
};
}
+24
View File
@@ -6,9 +6,33 @@ export type {
WorkbenchMapControllerState,
WorkbenchMapErrorCode
} from "./map/workbench-map-controller";
export type {
PipeFlowCalculation,
PipeFlowDirection,
PipeFlowSummary
} from "./map/flow-overlay";
export type {
LayerGroupId,
LayerGroupStylePatch,
LineLayerGroupStylePatch,
PointLayerGroupStylePatch
} 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";
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { getWorkbenchBasemapTone } from "./workbench-layout";
import {
clampAgentPanelWidth,
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth,
getWorkbenchBasemapTone,
getWorkbenchCameraPadding,
getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout";
describe("workbench basemap surface tone", () => {
it.each([
@@ -11,3 +19,90 @@ describe("workbench basemap surface tone", () => {
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
});
});
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", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500,
conditionWidth: 432,
conditionExpandedWidth: 880,
toolbarWidth: 48
});
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
agentWidth: 540,
conditionExpandedWidth: 960
});
});
it("tracks the expanded condition panel in camera padding", () => {
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: true,
conditionOpen: true,
conditionExpanded: false
})
).toEqual({ top: 72, right: 504, bottom: 32, left: 524 });
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: true,
conditionOpen: true,
conditionExpanded: true
})
).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", () => {
expect(
getWorkbenchCameraPadding(1920, {
agentOpen: true,
agentWidth: 620,
conditionOpen: true
})
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
expect(
getWorkbenchCameraPadding(1920, {
agentOpen: true,
agentWidth: 900,
conditionOpen: false
}).left
).toBe(744);
});
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
expect(getAgentPanelDefaultWidth(390)).toBe(500);
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
expect(getAgentPanelMaxWidth(1024)).toBe(484);
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
expect(getAgentPanelMaxWidth(1280)).toBe(516);
expect(getAgentPanelMaxWidth(1440)).toBe(676);
expect(getAgentPanelMaxWidth(1536)).toBe(720);
expect(getAgentPanelMaxWidth(1920)).toBe(720);
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
});
});
@@ -3,33 +3,101 @@ export const WORKBENCH_LAYOUT = {
desktopMinWidth: 1024,
persistentConditionMinWidth: 1280,
wideMinWidth: 1536,
collapsedAgentWidth: 72,
maxAgentViewportRatio: 0.5,
collapsedAgentWidth: 0,
maxAgentWidth: 720,
agentPanelLeftInset: 12,
conditionPanelRightInset: 64,
minimumAgentMapCorridor: 256,
mapEdgeGap: 24,
desktop: {
agentWidth: 460,
agentWidth: 500,
conditionWidth: 432,
conditionExpandedWidth: 880,
toolbarWidth: 48,
tickerWidth: 420
},
wide: {
agentWidth: 500,
agentWidth: 540,
conditionWidth: 432,
conditionExpandedWidth: 960,
toolbarWidth: 48,
tickerWidth: 460
},
mobileSheet: {
halfViewportRatio: 0.52,
fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
}
} as const;
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth(
width: number,
viewportWidth: number,
conditionExpanded = false
) {
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
return Math.round(
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
);
}
export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpanded = false) {
const layout = getWorkbenchViewportLayout(viewportWidth);
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
return layout.agentWidth;
}
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
const collisionSafeWidth =
viewportWidth -
WORKBENCH_LAYOUT.agentPanelLeftInset -
WORKBENCH_LAYOUT.mapEdgeGap -
conditionWidth -
WORKBENCH_LAYOUT.mapEdgeGap -
layout.toolbarWidth;
return Math.min(layout.agentWidth, collisionSafeWidth);
}
export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded = false) {
const layout = getWorkbenchViewportLayout(viewportWidth);
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const widthWithMapCorridor =
viewportWidth -
WORKBENCH_LAYOUT.agentPanelLeftInset -
WORKBENCH_LAYOUT.conditionPanelRightInset -
conditionWidth -
WORKBENCH_LAYOUT.minimumAgentMapCorridor;
return Math.max(defaultWidth, Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithMapCorridor));
}
export type WorkbenchPanelState = {
agentOpen: boolean;
agentWidth?: number;
conditionOpen: boolean;
conditionExpanded?: boolean;
};
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
@@ -37,6 +105,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
export type WorkbenchViewportLayout = {
agentWidth: number;
conditionWidth: number;
conditionExpandedWidth: number;
toolbarWidth: number;
tickerWidth: number;
};
@@ -65,8 +134,23 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
}
const layout = getWorkbenchViewportLayout(viewportWidth);
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
const agentWidth = panels.agentOpen
? panels.agentWidth === undefined
? getAgentPanelDefaultWidth(
viewportWidth,
panels.conditionOpen && Boolean(panels.conditionExpanded)
)
: clampAgentPanelWidth(
panels.agentWidth,
viewportWidth,
panels.conditionOpen && Boolean(panels.conditionExpanded)
)
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const conditionWidth = panels.conditionOpen
? panels.conditionExpanded
? layout.conditionExpandedWidth
: layout.conditionWidth
: 0;
return {
top: WORKBENCH_LAYOUT.headerHeight + 16,
@@ -82,6 +166,8 @@ export function getWorkbenchLayoutCssVariables() {
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
"--workbench-condition-expanded-width-wide": `${WORKBENCH_LAYOUT.wide.conditionExpandedWidth}px`,
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
+445 -120
View File
@@ -2,18 +2,22 @@ import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ComponentProps,
type CSSProperties
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarClock, Clock3 } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult
type AgentUiResult,
type AgentWorkspaceContextItem
} from "@/features/agent";
import {
MapErrorNotice,
@@ -27,11 +31,22 @@ import {
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
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 {
ToolbarPanel,
type ExportViewPreset,
@@ -39,11 +54,20 @@ import {
type ToolbarToolId
} from "./components/toolbar-panel";
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 { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
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 { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
@@ -54,7 +78,8 @@ import {
import {
WORKBENCH_LAYOUT,
getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables
getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
@@ -63,7 +88,6 @@ import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility,
createLayerControlItems,
getWorkbenchLayerIds
@@ -73,7 +97,8 @@ import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
WorkbenchAlert
WorkbenchAlert,
WorkbenchUser
} from "./types";
import {
createScheduledConditionAlerts,
@@ -83,19 +108,23 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
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 devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
@@ -128,68 +157,123 @@ export function MapWorkbenchPage() {
setDetailFeature(feature);
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
};
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({
onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
onFrontendAction: handleFrontendAction,
getAccessToken
});
function openAgentPanelForViewport() {
if (isLargeScreen) {
agent.expandPanel();
return;
}
agent.openMobilePanel();
}
const leftPanelOpen = isLargeScreen && agent.panelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => {
setActiveToolId(null);
}, []);
const {
agentPanelWidth,
closeMobileSheet,
conditionFeedExpanded,
conditionFeedMounted,
handleConditionExpandedChange,
isLargeScreen,
leftPanelOpen,
mobileSheet,
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(() => {
setTimelineMode((current) => {
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();
}
function openConditionWorkspaceForViewport() {
openConditionFeedForViewport();
}
function toggleConditionWorkspaceForViewport() {
toggleConditionFeedForViewport();
}
function toggleTimelineVisibility() {
setTimelineVisible((current) => {
if (current && mobileSheet === "timeline") closeMobileSheet();
return !current;
});
}
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({
containerRef: mapContainerRef,
impactVisible,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
selectedFeature: detailFeature,
getAccessToken
});
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
leftPanelOpen: false,
rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth,
getScadaFeatures
});
const activeAgentUiResults = useMemo(
@@ -223,10 +307,13 @@ export function MapWorkbenchPage() {
mode: activeMeasureModeId,
unit: activeMeasureUnitId
});
const prefersReducedMotion = useReducedMotion();
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
!artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& taskTickerVisible
&& taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
@@ -279,6 +366,54 @@ export function MapWorkbenchPage() {
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
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[]>(
() =>
waterNetworkToolbarItems.map((item) => ({
@@ -307,9 +442,7 @@ export function MapWorkbenchPage() {
}
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
});
}
@@ -404,11 +537,10 @@ export function MapWorkbenchPage() {
setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true }));
if (mapRef.current && mapReady) {
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
if (mapRef.current?.getLayer(layerId)) {
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
}
const map = mapRef.current;
if (map && mapReady) {
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
map.setLayoutProperty(layerId, "visibility", "visible");
});
}
@@ -450,7 +582,7 @@ export function MapWorkbenchPage() {
return;
}
openAgentPanelForViewport();
openAgentWorkspaceForViewport();
try {
await agent.submitPrompt(
@@ -487,8 +619,7 @@ export function MapWorkbenchPage() {
return;
}
setConditionFeedVisible(true);
setActiveToolId(null);
openConditionWorkspaceForViewport();
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
@@ -608,7 +739,13 @@ export function MapWorkbenchPage() {
map.easeTo({
center: trustedAction.center,
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
padding: getResponsiveWorkbenchPadding(
map,
false,
rightPanelOpen,
rightPanelExpanded,
agentPanelWidth
),
duration: trustedAction.durationMs ?? 500
});
showMapNotice({
@@ -722,6 +859,35 @@ export function MapWorkbenchPage() {
onShowShortcuts: handleShowShortcuts,
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<
ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse"
@@ -732,6 +898,7 @@ export function MapWorkbenchPage() {
sessionHistoryLoading: agent.sessionHistoryLoading,
activeSessionId: agent.sessionId,
statusLabel: agent.statusLabel,
connectionStatus: agent.connectionStatus,
streaming: agent.streaming,
messages: agent.messages,
streamRenderState: agent.streamRenderState,
@@ -739,6 +906,7 @@ export function MapWorkbenchPage() {
selectedModel: agent.selectedModel,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -757,91 +925,166 @@ export function MapWorkbenchPage() {
<main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
data-basemap-tone={basemapTone}
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
style={
{
...WORKBENCH_LAYOUT_CSS_VARIABLES,
"--workbench-agent-current-width": `${currentAgentWidth}px`,
"--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
} as CSSProperties
}
>
<div
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="AquaDispatch v1.0"
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={WORKBENCH_USER}
conditionFeedVisible={shouldShowConditionFeed}
user={user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={() => {
setConditionFeedVisible((current) => !current);
setActiveToolId(null);
}}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
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
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
mobileOpen={agent.mobileOpen}
mobilePanelCollapsing={agent.mobilePanelCollapsing}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
viewportWidth={viewportWidth}
onCollapsePanel={agent.collapsePanel}
onCloseMobilePanel={agent.closeMobilePanel}
onExpandPanel={agent.expandPanel}
onOpenMobilePanel={agent.openMobilePanel}
onWidthCommit={setAgentPanelWidth}
/>
{!devPanelOpen ? (
<div className="absolute right-2 top-24 z-20">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
) : null}
{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
presentation="desktop-floating"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible={shouldShowConditionFeed}
selectedConditionId={selectedConditionId}
onExpandedChange={setConditionFeedExpanded}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
{!devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
) : null}
{devPanelEnabled && devPanelOpen ? (
<MapDevPanel
commands={mapController}
@@ -851,8 +1094,11 @@ export function MapWorkbenchPage() {
/>
) : null}
{!agent.mobileOpen ? (
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<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
{...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -860,18 +1106,11 @@ export function MapWorkbenchPage() {
</div>
) : 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}>
{shouldShowTaskTicker ? (
<motion.div
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" }}
initial={
prefersReducedMotion
@@ -890,15 +1129,93 @@ export function MapWorkbenchPage() {
) : null}
</AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
{!agent.mobileOpen ? (
<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 ? (
<div className="flex items-center justify-center gap-2">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={openAgentPanelForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
>
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
</button>
{artifactWorkspace.surfaceMode !== "focus" ? (
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="w-full justify-center"
className="h-11 justify-center"
/>
) : null}
<button
type="button"
aria-label="打开工况任务"
title="打开工况任务"
onClick={toggleConditionFeedForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
>
<CalendarClock size={20} aria-hidden="true" />
</button>
</div>
) : null}
</div>
{mobileSheet ? (
<MobileWorkbenchSheet
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
: mobileSheet === "timeline"
? "运行时间轴抽屉"
: "工况任务抽屉"
}
snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet}
>
{mobileSheet === "agent" ? (
<AgentCommandPanel
{...agentPanelProps}
presentation="mobile-sheet"
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
presentation="mobile-sheet"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
)}
</MobileWorkbenchSheet>
) : null}
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
@@ -951,3 +1268,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
return "当前分辨率";
}
function formatContextClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
@@ -72,7 +72,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold"],
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
@@ -89,7 +89,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Open Sans Bold"],
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
@@ -107,7 +107,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Regular"],
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
@@ -124,7 +124,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Open Sans Regular"],
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
+35 -11
View File
@@ -1,10 +1,6 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
fitNetworkBounds,
getResponsiveWorkbenchPadding,
getWorkbenchPadding
} from "./camera";
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) {
@@ -19,12 +15,40 @@ function createMap(width = 1280, height = 720) {
describe("workbench camera padding", () => {
it.each([
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
{
agentOpen: false,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 24 }
},
{
agentOpen: true,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 524 }
},
{
agentOpen: false,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 24 }
},
{
agentOpen: true,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 524 }
}
])(
"covers agent=$agentOpen and condition=$conditionOpen",
({ agentOpen, conditionOpen, expected }) => {
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
}
);
it("uses a committed Agent width for camera-safe operations", () => {
expect(getWorkbenchPadding(true, false, false, 620)).toEqual({
top: 72,
right: 72,
bottom: 32,
left: 540
});
});
it("caps combined padding on constrained desktop widths", () => {
@@ -59,7 +83,7 @@ describe("fitNetworkBounds", () => {
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
expect(bounds[0][1]).toBeCloseTo(30.8105, 6);
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({
+19 -16
View File
@@ -1,7 +1,5 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import {
getWorkbenchCameraPadding
} from "../layout/workbench-layout";
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
type LngLatBounds = [[number, number], [number, number]];
@@ -14,24 +12,35 @@ export type NetworkViewParams = {
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
export function getWorkbenchPadding(
leftPanelOpen: boolean,
rightPanelOpen: boolean,
conditionPanelExpanded = false,
agentPanelWidth?: number
): PaddingOptions {
return getWorkbenchCameraPadding(1280, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
agentWidth: agentPanelWidth,
conditionOpen: rightPanelOpen,
conditionExpanded: conditionPanelExpanded
});
}
export function getResponsiveWorkbenchPadding(
map: MapLibreMap,
leftPanelOpen: boolean,
rightPanelOpen: boolean
rightPanelOpen: boolean,
conditionPanelExpanded = false,
agentPanelWidth?: number
): PaddingOptions {
const width = getMapViewportSize(map).width;
return getResponsivePadding(
map,
getWorkbenchCameraPadding(width, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
agentWidth: agentPanelWidth,
conditionOpen: rightPanelOpen,
conditionExpanded: conditionPanelExpanded
})
);
}
@@ -41,14 +50,11 @@ export function fitNetworkBounds(
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = GLOBAL_VIEW_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
duration: 600
}
);
});
}
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
@@ -84,10 +90,7 @@ function getMapViewportSize(map: MapLibreMap) {
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
const [minX, minY, maxX, maxY] = bbox3857;
return [
webMercatorToLngLat(minX, minY),
webMercatorToLngLat(maxX, maxY)
];
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
@@ -9,7 +9,7 @@ import {
} from "./map-colors";
import { createBaseStyle } from "./sources";
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
const mapBackgrounds = [
MAP_STYLE_TOKENS.canvas.primary,
MAP_STYLE_TOKENS.canvas.secondary,
@@ -47,7 +47,6 @@ describe("map color contrast", () => {
});
it("keeps selected outlines above 4.5:1 on every map background", () => {
sourceColors.forEach(() => {
mapBackgrounds.forEach((background) => {
const selectedColor = MAP_STYLE_TOKENS.state.selected;
expect(
@@ -56,9 +55,27 @@ describe("map color contrast", () => {
).toBeGreaterThanOrEqual(4.5);
});
});
it("keeps operational states above 3:1 on every map background", () => {
const operationalColors = [
MAP_STYLE_TOKENS.state.success,
MAP_STYLE_TOKENS.state.risk,
MAP_STYLE_TOKENS.state.incident,
MAP_STYLE_TOKENS.state.inactive,
MAP_STYLE_TOKENS.state.agent
];
operationalColors.forEach((stateColor) => {
mapBackgrounds.forEach((background) => {
expect(
getContrastRatio(stateColor, background),
createCoolorsContrastUrl(stateColor, background)
).toBeGreaterThanOrEqual(3);
});
});
});
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
MAP_STYLE_TOKENS.state.inactive,
+3 -1
View File
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
});
if (selectedFeature) {
exportMap.setFeatureState(
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
selectedFeature.sourceLayer
? { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }
: { source: selectedFeature.source, id: selectedFeature.id },
{ selected: true, hovered: false }
);
}
+26 -7
View File
@@ -1,15 +1,19 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id;
const raw = feature.properties?.device_id ?? feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw);
}
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
return sourceId;
}
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
if (sourceId === "valves") {
return {
title: `阀门 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
};
}
if (sourceId === "reservoirs") {
return {
title: `水库 ${id || "未命名"}`,
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
};
}
if (sourceId === "scada") {
const associatedElementId = properties.node_id ?? properties.link_id;
return {
title: `SCADA ${id || "未命名"}`,
subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}`
subtitle: `${formatValue(properties.device_type)} · 关联 ${formatValue(associatedElementId)}`
};
}
if (sourceId === "pumps") {
return {
title: `水泵 ${id || "未命名"}`,
subtitle: `${formatValue(properties.start_node_id)} → ${formatValue(properties.end_node_id)}`
};
}
if (sourceId === "tanks") {
return {
title: `水箱 ${id || "未命名"}`,
subtitle: `初始水位 ${formatValue(properties.initial_level)} · 高程 ${formatValue(properties.elevation)} m`
};
}
return {
title: `节点 ${id || "未命名"}`,
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m`
subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m`
};
}
+169 -13
View File
@@ -1,21 +1,177 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
import {
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID,
clearPipeFlowCalculations,
createFlowOverlayState,
getPipeFlowDirection,
replacePipeFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation
} from "./flow-overlay";
describe("flow overlay lifecycle", () => {
it("starts empty so conduit data can be loaded once per map session", () => {
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
describe("flow overlay", () => {
it("classifies signed velocities relative to pipe geometry", () => {
expect(getPipeFlowDirection(2.5)).toBe(1);
expect(getPipeFlowDirection(-0.01)).toBe(-1);
expect(getPipeFlowDirection(0)).toBe(0);
expect(getPipeFlowDirection(Number.NaN)).toBe(0);
});
it("cancels an active animation frame without clearing cached data", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("window", { cancelAnimationFrame });
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
const { map, layers } = createMap();
const state = createFlowOverlayState();
state.data = [];
state.animationFrameId = 42;
stopFlowAnimation(state);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(state.animationFrameId).toBeNull();
expect(state.data).toBeTruthy();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
});
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
expect(
JSON.stringify((layers.get(FLOW_LINE_LAYER_ID) as { paint: Record<string, unknown> }).paint["line-opacity"])
).toContain('["%",["to-number",["id"],0],2]');
});
it("updates one pipe direction without rebuilding its vector source", () => {
const { map } = createMap();
const state = createFlowOverlayState();
const first = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: -2.4
});
const second = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: 1.2
});
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
source: "pipes",
sourceLayer: "pipes",
id: "P-1"
}, {
flowDirection: -1
});
expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), {
flowDirection: 1
});
expect(first).toMatchObject({ total: 1, reverse: 1 });
expect(second).toMatchObject({ total: 1, forward: 1 });
expect(map.getSource).not.toHaveBeenCalled();
});
it("replaces a snapshot and clears only stale flow state keys", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-old",
velocity: 1
});
map.removeFeatureState.mockClear();
const summary = await replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
[
{ pipeId: "P-new", velocity: -3 },
{ pipeId: "P-new", velocity: 4 },
{ pipeId: "", velocity: 1 }
]
);
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({
id: "P-old"
}), "flowDirection");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(summary).toEqual({
total: 1,
forward: 1,
reverse: 0,
stopped: 0,
invalid: 1
});
});
it("returns to the all-pipe mock after clearing explicit calculations", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: 1
});
await clearPipeFlowCalculations(map as unknown as MapLibreMap, state);
const latestOpacity = map.setPaintProperty.mock.calls
.filter(([, property]) => property === "line-opacity")
.at(-1)?.[2];
expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]');
});
it("applies large snapshots in animation-frame batches", async () => {
const callbacks: FrameRequestCallback[] = [];
vi.stubGlobal("window", {
matchMedia: () => ({ matches: false }),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callbacks.push(callback);
return callbacks.length;
}),
cancelAnimationFrame: vi.fn()
});
const { map } = createMap();
const state = createFlowOverlayState();
const records = Array.from({ length: 2_001 }, (_, index) => ({
pipeId: index,
velocity: index % 2 === 0 ? 1 : -1
}));
const pending = replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
records
);
expect(map.setFeatureState).toHaveBeenCalledTimes(2_000);
callbacks.shift()?.(16);
const summary = await pending;
expect(map.setFeatureState).toHaveBeenCalledTimes(2_001);
expect(summary.total).toBe(2_001);
vi.unstubAllGlobals();
});
});
function createMap() {
const layers = new Map<string, unknown>();
const images = new Set<string>();
const map = {
addImage: vi.fn((id: string) => {
images.add(id);
}),
addLayer: vi.fn((layer: { id: string }) => {
layers.set(layer.id, layer);
return map;
}),
getLayer: vi.fn((id: string) => layers.get(id)),
getSource: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
removeFeatureState: vi.fn(),
setFeatureState: vi.fn(),
setLayoutProperty: vi.fn(),
setPaintProperty: vi.fn((id: string, property: string, value: unknown) => {
const layer = layers.get(id) as { paint?: Record<string, unknown> } | undefined;
if (layer?.paint) layer.paint[property] = value;
}),
triggerRepaint: vi.fn()
};
return { map, layers };
}
+405 -67
View File
@@ -1,99 +1,437 @@
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { MapboxOverlay } from "@deck.gl/mapbox";
import type {
ExpressionSpecification,
Map as MapLibreMap,
StyleImageInterface
} from "maplibre-gl";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_LAYERS } from "./sources";
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward";
export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse";
const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward";
const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse";
const FLOW_STATE_DIRECTION = "flowDirection";
const FLOW_PATTERN_WIDTH = 64;
const FLOW_PATTERN_HEIGHT = 8;
const FLOW_PATTERN_DURATION_MS = 1_200;
export type PipeFlowCalculation = {
pipeId: string | number;
velocity: number;
};
export type PipeFlowDirection = -1 | 0 | 1;
export type PipeFlowSummary = {
total: number;
forward: number;
reverse: number;
stopped: number;
invalid: number;
};
type NormalizedPipeFlowCalculation = {
pipeId: string | number;
direction: PipeFlowDirection;
};
export type FlowOverlayState = {
data: FlowFeature[] | null;
animationFrameId: number | null;
overlay: MapboxOverlay | null;
calculations: Map<string, NormalizedPipeFlowCalculation>;
revision: number;
visible: boolean;
};
export function createFlowOverlayState(): FlowOverlayState {
return { data: null, animationFrameId: null, overlay: null };
return {
calculations: new Map(),
revision: 0,
visible: false
};
}
export async function showFlowOverlay(
map: MapLibreMap,
state: FlowOverlayState,
loadData: () => Promise<FeatureCollection>,
_state: FlowOverlayState,
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
if (!state.data) {
const collection = await loadData();
state.data = collection.features.filter(isFlowFeature);
ensureFlowImages(map, reducedMotion);
ensureFlowLayers(map);
setFlowMockVisibility(map, _state.calculations.size === 0);
_state.visible = true;
setFlowLayerVisibility(map, "visible");
}
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
import("@deck.gl/mapbox"),
import("@deck.gl/layers"),
import("./flow-path-extension")
]);
const extension = new FlowPathExtension();
let renderingError: Error | null = null;
const render = (phase: number) => {
const layer = new PathLayer<FlowFeature>({
id: "workbench-network-flow",
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
data: state.data ?? [],
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
getColor: [15, 118, 110, 210],
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
widthUnits: "pixels",
widthMinPixels: 1,
widthMaxPixels: 5,
capRounded: true,
jointRounded: true,
pickable: false,
parameters: { depthTest: false },
extensions: [extension],
flowPhase: phase
} as never);
state.overlay?.setProps({
layers: [layer],
onError: (error) => {
renderingError = error;
export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) {
_state.visible = false;
setFlowLayerVisibility(map, "none");
}
export async function replacePipeFlowCalculations(
map: MapLibreMap,
state: FlowOverlayState,
records: readonly PipeFlowCalculation[]
): Promise<PipeFlowSummary> {
const revision = ++state.revision;
const { calculations: next, invalid } = normalizePipeFlowCalculations(records);
const changes: FlowStateChange[] = [];
setFlowMockVisibility(map, false);
state.calculations.forEach((_, key) => {
if (!next.has(key)) changes.push({ type: "remove", key });
});
next.forEach((calculation, key) => {
const previous = state.calculations.get(key);
if (
previous?.direction !== calculation.direction
) {
changes.push({ type: "set", key, calculation });
}
});
if (state.visible && changes.length > 0) {
setFlowLayerVisibility(map, "none");
}
await applyFlowStateChanges(map, state, changes, revision);
if (state.visible && revision === state.revision) {
setFlowLayerVisibility(map, "visible");
}
return summarizePipeFlowCalculations(state.calculations.values(), invalid);
}
export function updatePipeFlowCalculation(
map: MapLibreMap,
state: FlowOverlayState,
record: PipeFlowCalculation
): PipeFlowSummary {
const calculation = normalizePipeFlowCalculation(record);
if (!calculation) throw new Error("INVALID_FLOW_CALCULATION");
state.revision += 1;
const key = String(calculation.pipeId);
const previous = state.calculations.get(key);
if (
previous?.direction !== calculation.direction
) {
setPipeFlowState(map, calculation);
state.calculations.set(key, calculation);
}
if (state.visible) setFlowLayerVisibility(map, "visible");
return summarizePipeFlowCalculations(state.calculations.values(), 0);
}
export function clearPipeFlowCalculations(
map: MapLibreMap,
state: FlowOverlayState
) {
return replacePipeFlowCalculations(map, state, []).then((summary) => {
if (state.visible) {
setFlowMockVisibility(map, true);
}
return summary;
});
}
export function getPipeFlowDirection(velocity: number): PipeFlowDirection {
if (!Number.isFinite(velocity) || velocity === 0) return 0;
return velocity < 0 ? -1 : 1;
}
type FlowStateChange =
| { type: "remove"; key: string }
| {
type: "set";
key: string;
calculation: NormalizedPipeFlowCalculation;
};
if (!state.overlay) {
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
map.addControl(overlay);
state.overlay = overlay;
function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) {
const calculations = new Map<string, NormalizedPipeFlowCalculation>();
let invalid = 0;
records.forEach((record) => {
const normalized = normalizePipeFlowCalculation(record);
if (!normalized) {
invalid += 1;
return;
}
calculations.set(String(normalized.pipeId), normalized);
});
return { calculations, invalid };
}
stopFlowAnimation(state);
render(0.2);
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (renderingError) throw renderingError;
if (!reducedMotion) {
const startedAt = performance.now();
const tick = (now: number) => {
render(((now - startedAt) / 1800) % 1);
state.animationFrameId = window.requestAnimationFrame(tick);
function normalizePipeFlowCalculation(
record: PipeFlowCalculation
): NormalizedPipeFlowCalculation | null {
if (
(typeof record.pipeId !== "string" && typeof record.pipeId !== "number")
|| String(record.pipeId).trim().length === 0
|| !Number.isFinite(record.velocity)
) {
return null;
}
return {
pipeId: record.pipeId,
direction: getPipeFlowDirection(record.velocity)
};
state.animationFrameId = window.requestAnimationFrame(tick);
}
function summarizePipeFlowCalculations(
calculations: Iterable<NormalizedPipeFlowCalculation>,
invalid: number
): PipeFlowSummary {
const summary: PipeFlowSummary = {
total: 0,
forward: 0,
reverse: 0,
stopped: 0,
invalid
};
for (const calculation of calculations) {
summary.total += 1;
if (calculation.direction > 0) summary.forward += 1;
else if (calculation.direction < 0) summary.reverse += 1;
else summary.stopped += 1;
}
return summary;
}
function setPipeFlowState(
map: MapLibreMap,
calculation: NormalizedPipeFlowCalculation
) {
map.setFeatureState(
{
source: "pipes",
sourceLayer: SOURCE_LAYERS.pipes,
id: calculation.pipeId
},
{
[FLOW_STATE_DIRECTION]: calculation.direction
}
);
}
async function applyFlowStateChanges(
map: MapLibreMap,
state: FlowOverlayState,
changes: readonly FlowStateChange[],
revision: number
) {
const batchSize = 2_000;
let offset = 0;
await new Promise<void>((resolve) => {
const applyBatch = () => {
if (revision !== state.revision) {
resolve();
return;
}
const end = Math.min(offset + batchSize, changes.length);
for (; offset < end; offset += 1) {
const change = changes[offset];
if (change.type === "remove") {
removePipeFlowState(map, change.key);
state.calculations.delete(change.key);
} else {
setPipeFlowState(map, change.calculation);
state.calculations.set(change.key, change.calculation);
}
}
if (offset >= changes.length) {
resolve();
return;
}
window.requestAnimationFrame(applyBatch);
};
applyBatch();
});
}
function removePipeFlowState(map: MapLibreMap, id: string | number) {
const target = {
source: "pipes",
sourceLayer: SOURCE_LAYERS.pipes,
id
};
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
}
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
map.addImage(
FLOW_FORWARD_IMAGE_ID,
createFlowPatternImage(1, reducedMotion),
{ pixelRatio: 1 }
);
}
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
map.addImage(
FLOW_REVERSE_IMAGE_ID,
createFlowPatternImage(-1, reducedMotion),
{ pixelRatio: 1 }
);
}
}
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
stopFlowAnimation(state);
if (state.overlay) {
map.removeControl(state.overlay as never);
state.overlay = null;
function createFlowPatternImage(
direction: -1 | 1,
reducedMotion: boolean
): StyleImageInterface {
let map: MapLibreMap | null = null;
let lastOffset = Number.NaN;
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
const draw = (offset: number) => {
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
const wrapped = (
x - offset + FLOW_PATTERN_WIDTH
) % FLOW_PATTERN_WIDTH;
const pulse = wrapped < 18
? Math.sin((Math.PI * wrapped) / 18)
: 0;
const alpha = Math.round(255 * pulse * edgeFade);
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
data[index] = 94;
data[index + 1] = 234;
data[index + 2] = 212;
data[index + 3] = alpha;
}
}
};
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
return {
width: FLOW_PATTERN_WIDTH,
height: FLOW_PATTERN_HEIGHT,
data,
onAdd(nextMap) {
map = nextMap;
},
render() {
if (reducedMotion) return false;
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
/ FLOW_PATTERN_DURATION_MS;
const offset = Math.floor(
progress * FLOW_PATTERN_WIDTH * direction
+ FLOW_PATTERN_WIDTH
) % FLOW_PATTERN_WIDTH;
if (offset === lastOffset) {
map?.triggerRepaint();
return false;
}
lastOffset = offset;
draw(offset);
map?.triggerRepaint();
return true;
}
};
}
export function stopFlowAnimation(state: FlowOverlayState) {
if (state.animationFrameId !== null) {
window.cancelAnimationFrame(state.animationFrameId);
state.animationFrameId = null;
const mockDirectionExpression: ExpressionSpecification = [
"case",
[
"==",
["%", ["to-number", ["id"], 0], 2],
0
],
1,
-1
];
const directionExpression = (
direction: -1 | 1,
visibleOpacity: number,
mockVisible = false
): ExpressionSpecification => [
"case",
[
"==",
[
"coalesce",
["feature-state", FLOW_STATE_DIRECTION],
mockVisible ? mockDirectionExpression : 0
],
direction
],
visibleOpacity,
0
];
function ensureFlowLayers(map: MapLibreMap) {
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
map.addLayer({
id: FLOW_LINE_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
layout: flowLineLayout(),
paint: {
"line-pattern": FLOW_FORWARD_IMAGE_ID,
"line-width": 4,
"line-opacity": directionExpression(1, 0.92)
}
}, WORKBENCH_INTERACTION_BEFORE_ID);
}
function isFlowFeature(feature: Feature): feature is FlowFeature {
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
map.addLayer({
id: FLOW_REVERSE_LINE_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
layout: flowLineLayout(),
paint: {
"line-pattern": FLOW_REVERSE_IMAGE_ID,
"line-width": 4,
"line-opacity": directionExpression(-1, 0.92)
}
}, WORKBENCH_INTERACTION_BEFORE_ID);
}
}
function flowLineLayout() {
return {
visibility: "visible" as const,
"line-cap": "round" as const,
"line-join": "round" as const
};
}
function setFlowLayerVisibility(
map: MapLibreMap,
visibility: "visible" | "none"
) {
[
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID
].forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
function setFlowMockVisibility(
map: MapLibreMap,
visible: boolean
) {
const opacityByLayer = [
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
] as const;
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
if (map.getLayer(layerId)) {
map.setPaintProperty(
layerId,
property,
directionExpression(direction, opacity, visible)
);
}
});
}
@@ -1,34 +0,0 @@
import { LayerExtension, type Layer } from "@deck.gl/core";
const flowPathUniforms = {
name: "flowPath",
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
uniformTypes: { phase: "f32" }
} as const;
export class FlowPathExtension extends LayerExtension {
static extensionName = "FlowPathExtension";
static defaultProps = { flowPhase: { type: "number", value: 0 } };
getShaders() {
return {
modules: [flowPathUniforms],
inject: {
"fs:DECKGL_FILTER_COLOR": `
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
color.a *= 0.22 + flowPulse * 0.78;
`
}
};
}
draw(this: Layer) {
const state = this.state as unknown as {
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
};
state.model?.shaderInputs?.setProps({
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
});
}
}
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
iconMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
setLayout(
map,
`${groupId}-symbol`,
"icon-size",
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
if ("iconSize" in visual) {
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
}
}
function scale(value: unknown, multiplier: number): unknown {
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
map.setLayoutProperty(layerId, property, (value ?? null) as never);
}
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
+9 -1
View File
@@ -34,6 +34,14 @@ describe("supply network layer styling", () => {
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
});
expect(getLayer("pumps-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
});
expect(getLayer("tanks-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -47,7 +55,7 @@ describe("supply network layer styling", () => {
const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("reservoirs-hit");
expect(ids.at(-1)).toBe("tanks-hit");
});
});
+61 -1
View File
@@ -22,6 +22,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID
] as const;
@@ -208,6 +210,38 @@ layers.push(
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "pumps-symbol",
type: "symbol",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "tanks-symbol",
type: "symbol",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
}
);
@@ -295,7 +329,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
const pointInteractions = [
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir]
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir],
["pumps", "pumps", SOURCE_LAYERS.pumps, SUPPLY_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.supply.pump],
["tanks", "tanks", SOURCE_LAYERS.tanks, SUPPLY_LAYER_VISUALS.tanks, MAP_STYLE_TOKENS.supply.tank]
] as const;
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
@@ -403,6 +439,30 @@ layers.push(
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
"circle-opacity": 0
}
},
{
id: "pumps-hit",
type: "circle",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.pumps.hitRadius,
"circle-opacity": 0
}
},
{
id: "tanks-hit",
type: "circle",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.tanks.hitRadius,
"circle-opacity": 0
}
}
);
+19 -16
View File
@@ -7,30 +7,33 @@ export const MAP_STYLE_TOKENS = {
casing: "#FFFFFF",
transparent: "rgba(0,0,0,0)"
},
asset: {
conduit: "#0F766E",
junction: "#0369A1",
orifice: "#475569",
pump: "#155E75",
outfall: "#334155"
},
supply: {
pipeMinor: "#5A9E8A",
pipeMinor: "#3F7F70",
pipeMedium: "#217C83",
pipeMajor: "#245E75",
junction: "#5B8E91",
valve: "#8A8A8A",
junction: "#47787B",
valve: "#6B7280",
reservoir: "#195F84",
scadaFlow: "#0094BC",
scadaPressure: "#FFB624"
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E",
scadaPressure: "#9A6700"
},
// Map overlays stay more chromatic than UI status surfaces so they remain legible over imagery.
state: {
selected: "#2563EB",
inactive: "#64748B",
risk: "#C2410C",
incident: "#B91C1C",
success: "#15803D",
agent: "#6D28D9"
risk: "#C66A16",
incident: "#C34A4A",
success: "#2E8B64",
agent: "#7656C7"
},
stateLabel: {
neutral: "#334155",
warning: "#8C470D",
danger: "#9C3036",
success: "#1F6F4D",
agent: "#573E9C"
},
tool: {
geometry: "#334155"
@@ -19,16 +19,18 @@ describe("workbench supply layer controls", () => {
"valves",
"reservoirs",
"scada",
"pumps",
"tanks",
"simulation"
]);
expect(items.map((item) => item.id)).not.toContain("pumps");
expect(items.map((item) => item.id)).not.toContain("tanks");
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"tjwater:geo_pipes_mat",
"tjwater:geo_junctions_mat",
"tjwater:geo_valves",
"tjwater:geo_reservoirs",
"tjwater:geo_scada"
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
"tjwater_next:pipes",
"tjwater_next:junctions",
"tjwater_next:valves",
"tjwater_next:reservoirs",
"后端业务 API",
"tjwater_next:pumps",
"tjwater_next:tanks"
]);
});
@@ -41,6 +43,8 @@ describe("workbench supply layer controls", () => {
"junction",
"valve",
"reservoir",
"pump",
"tank",
"scada-flow",
"scada-pressure",
"impact-area"
@@ -50,6 +54,8 @@ describe("workbench supply layer controls", () => {
label: "水库",
imageSrc: "/map/reservoir.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
@@ -82,5 +88,12 @@ describe("workbench supply layer controls", () => {
"reservoirs-selected",
"reservoirs-hit"
]);
expect(getWorkbenchLayerIds(map, "pumps")).toEqual([
"pumps-symbol",
"pumps-hover",
"pumps-selected-outer",
"pumps-selected",
"pumps-hit"
]);
});
});
@@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries(
layer.id,
{
label: layer.label,
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) as Record<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: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank },
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
MAX_CONDUIT_FEATURES,
MAX_PIPE_FEATURES,
MAP_FEATURE_ID_FIELDS,
createMapFeatureWfsUrl,
escapeCqlLiteral,
@@ -15,35 +15,37 @@ describe("map feature WFS query", () => {
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
});
expect(createMapFeatureWfsUrl({ sourceId: "junctions", featureIds: ["J-1"] }).searchParams.get("cql_filter"))
.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'");
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
expect(url.searchParams.get("typeNames")).toContain("tjwater_next:pipes");
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')");
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
});
it("uses the authoritative supply asset WFS layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs");
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
it("uses WFS only for authoritative supply asset layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs");
expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
ok: false,
code: "UNSUPPORTED_SOURCE"
});
});
it("limits ids and permits full-network requests only for pipes", () => {
expect(parseMapFeatureQuery({ sourceId: "pipes" })).toEqual({ ok: true, value: { sourceId: "pipes" } });
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_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: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
+12 -10
View File
@@ -2,26 +2,25 @@ import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
type WaterNetworkSourceId
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
type GeoServerWaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
};
export const MAX_MAP_FEATURE_IDS = 100;
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_CONDUIT_FEATURES = 5000;
export const MAX_PIPE_FEATURES = 5000;
export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId;
sourceId: GeoServerWaterNetworkSourceId;
featureIds?: string[];
};
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
}
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) {
if (
typeof sourceId !== "string" ||
!(GEOSERVER_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(sourceId)
) {
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
}
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
return {
ok: true,
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
};
}
@@ -75,11 +77,11 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
outputFormat: "application/json",
srsName: "EPSG:4326",
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES)
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
});
if (query.featureIds) {
params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
}
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
@@ -15,10 +15,10 @@ describe("SCADA analysis overlay", () => {
const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator);
const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label);
expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({
high: "#F43F5E",
medium: "#F59E0B",
low: "#10B981",
unrated: "#8B5CF6"
high: "#C34A4A",
medium: "#C66A16",
low: "#2E8B64",
unrated: "#7656C7"
});
expect(casing).toMatchObject({
type: "circle",
@@ -49,25 +49,26 @@ describe("SCADA analysis overlay", () => {
},
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 4, "text-halo-blur": 0.5 }
});
expect(JSON.stringify(label)).toContain("#991B1B");
expect(JSON.stringify(label)).toContain("#92400E");
expect(JSON.stringify(label)).toContain("#166534");
expect(JSON.stringify(label)).toContain("#9C3036");
expect(JSON.stringify(label)).toContain("#8C470D");
expect(JSON.stringify(label)).toContain("#1F6F4D");
expect(JSON.stringify(label)).toContain("#573E9C");
});
it("validates one to one hundred unique IDs and fixed levels", () => {
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
{ sensor_id: "MP01", level: "high" }
expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([
{ device_id: "MP01", level: "high" }
]);
expect(parseScadaAnalysisItems([])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull();
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "critical" }])).toBeNull();
});
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => {
it("uses trusted API device IDs for localized labels and reports partial misses", () => {
const result = createScadaAnalysisCollection(scadaCollection, [
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" }
{ device_id: "MP01", level: "high" },
{ device_id: "MP02", level: "medium" },
{ device_id: "MP404", level: "low" }
]);
expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"],
@@ -75,8 +76,8 @@ describe("SCADA analysis overlay", () => {
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
});
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]);
});
});
@@ -84,7 +85,7 @@ describe("SCADA analysis overlay", () => {
const scadaCollection: FeatureCollection = {
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } }
]
};
+31 -30
View File
@@ -1,5 +1,6 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
export const SCADA_ANALYSIS_LAYER_IDS = {
@@ -11,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = {
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisItem = { device_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = {
rendered_ids: string[];
@@ -21,17 +22,17 @@ export type ScadaAnalysisRenderResult = {
};
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#F43F5E",
medium: "#F59E0B",
low: "#10B981",
unrated: "#8B5CF6"
high: MAP_STYLE_TOKENS.state.incident,
medium: MAP_STYLE_TOKENS.state.risk,
low: MAP_STYLE_TOKENS.state.success,
unrated: MAP_STYLE_TOKENS.state.agent
};
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#991B1B",
medium: "#92400E",
low: "#166534",
unrated: "#5B21B6"
high: MAP_STYLE_TOKENS.stateLabel.danger,
medium: MAP_STYLE_TOKENS.stateLabel.warning,
low: MAP_STYLE_TOKENS.stateLabel.success,
unrated: MAP_STYLE_TOKENS.stateLabel.agent
};
const levelColorExpression = (
@@ -144,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const keys = Object.keys(entry);
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
if (
!sensorId ||
sensorId.length > 128 ||
!deviceId ||
deviceId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId)
seen.has(deviceId)
) return null;
seen.add(sensorId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
seen.add(deviceId);
items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
}
return items;
}
@@ -163,13 +164,13 @@ export function createScadaAnalysisCollection(
collection: FeatureCollection,
items: ScadaAnalysisItem[]
): { 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) => {
const sensorId = typeof feature.properties?.sensor_id === "string"
? feature.properties.sensor_id.trim()
const deviceId = typeof feature.properties?.device_id === "string"
? feature.properties.device_id.trim()
: "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>);
if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
featuresByDeviceId.set(deviceId, feature as Feature<Point>);
}
});
@@ -178,26 +179,26 @@ export function createScadaAnalysisCollection(
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id);
const feature = featuresByDeviceId.get(item.device_id);
if (!feature) {
missingIds.push(item.sensor_id);
missingIds.push(item.device_id);
return;
}
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
if (!trustedSensorId) {
missingIds.push(item.sensor_id);
const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
if (!trustedDeviceId) {
missingIds.push(item.device_id);
return;
}
renderedIds.push(trustedSensorId);
renderedIds.push(trustedDeviceId);
levelCounts[item.level] += 1;
features.push({
...feature,
properties: {
...feature.properties,
sensor_id: trustedSensorId,
device_id: trustedDeviceId,
analysis_level: item.level,
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
analysis_label: `${trustedDeviceId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
}
});
+8 -1
View File
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
describe("SCADA map presentation", () => {
it("maps the authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
@@ -28,6 +28,13 @@ describe("SCADA map presentation", () => {
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
});
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
[...scadaLayers, ...scadaFallbackLayers].forEach((layer) => {
expect("source" in layer ? layer.source : undefined).toBe("scada");
expect(layer).not.toHaveProperty("source-layer");
});
});
it("keeps the icon and selected ring consistent with junction selection", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
+10 -11
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "geo_scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
} as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
];
const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure
];
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
};
const scadaCasingLayer: Layer = {
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = {
};
const scadaFillLayer: Layer = {
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": createSupplyStateColor(scadaColor),
@@ -71,13 +70,13 @@ const scadaFillLayer: Layer = {
};
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
paint: { "icon-opacity": supplyIconOpacityExpression }
};
const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = {
};
export const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
];
const scadaHitLayer: Layer = {
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
};
+31 -16
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
createBaseStyle,
createWaterNetworkSources,
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS
@@ -10,13 +12,12 @@ import {
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
});
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
@@ -25,31 +26,45 @@ describe("createWaterNetworkSources", () => {
["valves", true],
["reservoirs", true],
["scada", true],
["pumps", false],
["tanks", false]
["pumps", true],
["tanks", true]
]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "geo_valves"
sourceLayer: "valves"
});
});
it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]);
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库",
icon: "reservoir"
});
const sources = createWaterNetworkSources();
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(sources[sourceId].tiles).toEqual([
expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
]);
expect(sources[sourceId].promoteId).toBe("id");
expect(sources[sourceId].bounds).toEqual([
121.35162408429075,
30.81049932304578,
121.77248479490075,
31.007207809222017
]);
});
expect("pumps" in sources).toBe(true);
expect("tanks" in sources).toBe(true);
expect("scada" in sources).toBe(false);
});
it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => {
expect(createBaseStyle().sources.scada).toMatchObject({
type: "geojson",
promoteId: "device_id"
});
expect("pumps" in sources).toBe(false);
expect("tanks" in sources).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More