Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c71cc076 | ||
|
|
28287a1447 | ||
|
|
abef9ffabf | ||
|
|
db696eb4f1 | ||
|
|
229cb0def2 | ||
|
|
1fb950806a | ||
|
|
073832034a | ||
|
|
57c494e8bd | ||
|
|
4a589d7a43 | ||
|
|
d39e58196f | ||
|
|
b93999646c | ||
|
|
b91be09f5c | ||
|
|
602b39d60a | ||
|
|
5e7101bfee | ||
|
|
21dbfd82b6 | ||
|
|
760370c93f | ||
|
|
3db95c27b5 | ||
|
|
45f1ebee7e | ||
|
|
d2f2895c36 | ||
|
|
bfadc95f93 | ||
|
|
be7b4149f2 | ||
|
|
1068304da5 | ||
|
|
f58e0a70f4 | ||
|
|
d08cf2abc1 | ||
|
|
bdd5eff776 | ||
|
|
bdf4436899 | ||
|
|
d29792a5c5 | ||
|
|
d1e69ad59b | ||
|
|
7216ffd60f | ||
|
|
b1b68d2754 | ||
|
|
2c35f500df | ||
|
|
3ba62ceacb | ||
|
|
73896a893f | ||
|
|
f4318a9b9c | ||
|
|
ade88b4fbf | ||
|
|
f8c79a2af3 | ||
|
|
f0485ca86e |
+5
-1
@@ -1,6 +1,10 @@
|
||||
TJWATER_AUTH_MODE=required
|
||||
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||
TJWATER_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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-*` 类。
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Generated
+17
-374
@@ -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
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Droplets, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function AuthStartupScreen({ error }: { error?: boolean }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
|
||||
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
|
||||
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
|
||||
<Droplets size={22} aria-hidden="true" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-lg font-semibold">
|
||||
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
|
||||
</p>
|
||||
{error ? (
|
||||
<Button className="mt-5" onClick={() => window.location.reload()}>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
重新连接
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||
|
||||
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
if (!init) throw new Error("Expected Agent request init");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("streams session events from the backend SSE endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -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,138 +278,238 @@ 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">
|
||||
<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} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
<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} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
</p>
|
||||
<StatusBadge
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
icon={
|
||||
<StatusDot
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{agentConnectionMetadata[connectionStatus].label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</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)
|
||||
)}
|
||||
/>
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
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"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<History 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"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<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>
|
||||
<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="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-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" }}
|
||||
>
|
||||
{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}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
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" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
aria-expanded={expanded}
|
||||
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} 条`}
|
||||
<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>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
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)} />
|
||||
<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}>
|
||||
{brief.task}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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-600" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -183,11 +183,24 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
|
||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
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: current.status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (firstNumeric) setProperty(firstNumeric[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>
|
||||
<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>
|
||||
<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={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="仅为当前测试目标显示一个数值属性。">
|
||||
<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} />}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
{children}
|
||||
<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">
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||||
{Icon ? (
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
) : (
|
||||
<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) => ({
|
||||
id: stage.id,
|
||||
title: stage.title,
|
||||
status: stage.status,
|
||||
description: stage.description,
|
||||
meta: [
|
||||
{ label: "责任人", value: stage.owner },
|
||||
{ label: "时间", value: stage.timeLabel }
|
||||
]
|
||||
}));
|
||||
const steps = condition.stages.map((stage) => {
|
||||
const awaitingReply = stage.id === "reply" && stage.status === "current";
|
||||
|
||||
return {
|
||||
id: stage.id,
|
||||
title: stage.title,
|
||||
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";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
||||
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||
}
|
||||
|
||||
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",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
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,
|
||||
"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">
|
||||
{children}
|
||||
<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 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
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="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 className="absolute left-3 top-5">
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
<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", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</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"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<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,28 +82,35 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
[resize]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
}, []);
|
||||
event.preventDefault();
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
|
||||
agent.openMobilePanel();
|
||||
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();
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
function openConditionWorkspaceForViewport() {
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleTimelineVisibility() {
|
||||
setTimelineVisible((current) => {
|
||||
if (current && mobileSheet === "timeline") closeMobileSheet();
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
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");
|
||||
}
|
||||
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,16 +1129,94 @@ export function MapWorkbenchPage() {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
||||
{!agent.mobileOpen ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
<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="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)} />
|
||||
|
||||
{!mapReady && !mapError ? (
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(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({
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
}
|
||||
);
|
||||
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,18 +47,35 @@ 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(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).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) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
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,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
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);
|
||||
}
|
||||
|
||||
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 isFlowFeature(feature: Feature): feature is FlowFeature {
|
||||
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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)] }))
|
||||
|
||||
@@ -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" } }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user