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

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00
jiang ade88b4fbf fix: distinguish awaiting work order replies 2026-07-27 14:58:29 +08:00
jiang f8c79a2af3 fix: refine mobile workbench sheets 2026-07-27 12:56:03 +08:00
jiang f0485ca86e feat: refresh workbench visual layout 2026-07-27 11:50:29 +08:00
140 changed files with 11680 additions and 2402 deletions
+3
View File
@@ -1,3 +1,6 @@
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
+22
View File
@@ -0,0 +1,22 @@
name: Frontend CI/CD
on:
push:
tags:
- "v*"
workflow_dispatch: {}
jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-frontend
dockerfile: Dockerfile
build_context: .
test_target: build
deploy_service: next-frontend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
+2 -2
View File
@@ -4,8 +4,8 @@
This repository contains the Vite and React single-page frontend for the
Agent-driven supply-network WebGIS. Runtime TypeScript lives under `src/`.
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, deck.gl,
SWR, Vitest, and Playwright.
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, SWR,
Vitest, and Playwright.
Keep this project browser-first. Do not add Next.js APIs, `"use client"`
directives, or general backend-for-frontend routes. Agent reasoning and tool
+12 -4
View File
@@ -1,17 +1,23 @@
FROM node:24-alpine AS build
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
ENV npm_config_registry=${NPM_REGISTRY}
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
COPY . .
RUN pnpm build
FROM caddy:2.10.2-alpine
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
RUN apk add --no-cache jq nodejs
ARG ALPINE_MIRROR=mirrors.aliyun.com
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache jq nodejs
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
COPY --from=build /app/dist /srv
@@ -19,5 +25,7 @@ COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-fr
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+20 -2
View File
@@ -2,7 +2,7 @@
Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以地图为主视图,集成供水管网图层、调度状态、事件处置、Agent 对话和受控前端动作。
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图与流向效果基于 MapLibre GL,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
## 主要功能
@@ -34,6 +34,9 @@ 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 工作区 |
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
浏览器使用 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_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
## 常用命令
@@ -70,6 +77,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 +105,4 @@ src/
## 构建说明
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+6
View File
@@ -2,6 +2,9 @@
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}" \
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
--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,
+40
View File
@@ -0,0 +1,40 @@
# 状态语言
系统使用五种语义状态。业务模块保留自己的领域枚举,并在展示边界映射到共享
`StatusTone`,不要让展示文案、接口字符串或 Tailwind 原色类直接决定状态样式。
| Tone | 用途 | 典型状态 |
| --- | --- | --- |
| `neutral` | 尚未开始或不再活动 | 预约、草案、待办、取消、中止、跳过、未分级 |
| `info` | 正在发生的过程 | 执行中、提交中、加载中、连接中 |
| `success` | 已完成或健康 | 完成、在线、正常、已批准 |
| `warning` | 需要关注但未达到异常 | 关注、降级、待复核、待复令、待确认 |
| `danger` | 已异常或操作失败 | 异常、失败、离线、高风险、事故、权限拒绝 |
## 报告风险
报告风险固定为三档:
- `normal` 映射 `success`,显示“正常”。
- `attention` 映射 `warning`,显示“关注”。
- `critical` 映射 `danger`,显示“异常”。
`info` 只表示过程,不参与风险等级。不得把 `attention` 合并到正常或异常。
## 表达规则
- 状态标签使用 `StatusBadge`,小型指示使用 `StatusDot`,图标节点使用 `StatusIcon`。
- 大面积容器只使用柔和背景,禁止实心红色或橙色状态卡片。
- `warning` 和 `danger` 必须同时提供文字或图标,不能只依赖颜色。
- 只有进行中的状态可以使用 `live` 或 `loading` 动效。
- “复令”是提交处置结果的动作,执行完成但尚未提交时显示“待复令”,使用静态
`warning`;不得继续显示为“执行中”或提前显示为完成态。
- 动效必须在 `prefers-reduced-motion` 下停止。
- 蓝色选中态属于交互反馈,不作为业务风险状态。
- 紫色只表示 Agent 身份,不表示未知或更高风险。
## 地图状态
地图与面板共享语义,但保留独立色值以适应浅色底图、影像底图和白色 casing。
新增地图状态时,应先补充 `map-colors.ts`,再通过对比度测试验证,不要在图层定义中
写入新的颜色字面量。
+7 -1
View File
@@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TJWater WebGIS Agent</title>
<link
rel="preload"
as="fetch"
href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf"
crossorigin="anonymous"
/>
<title>供水管网</title>
</head>
<body>
<div id="root"></div>
+5 -6
View File
@@ -6,7 +6,8 @@
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && pnpm build:tts",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
@@ -18,9 +19,7 @@
},
"dependencies": {
"@ai-sdk/react": "^4.0.9",
"@deck.gl/core": "^9.3.7",
"@deck.gl/layers": "^9.3.7",
"@deck.gl/mapbox": "^9.3.7",
"@fontsource-variable/noto-sans-sc": "5.3.0",
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17",
@@ -44,6 +43,7 @@
"echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0",
"keycloak-js": "26.2.4",
"lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1",
"motion": "^12.40.0",
@@ -72,14 +72,13 @@
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react-swc": "^4.0.2",
"esbuild": "^0.28.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"esbuild": "^0.28.0",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"msw": "^2.6.8",
"playwright": "^1.61.1",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
+9 -2
View File
@@ -2,18 +2,25 @@ import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
const runningInCi = Boolean(process.env.CI);
export default defineConfig({
testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true,
reporter: "html",
forbidOnly: runningInCi,
retries: runningInCi ? 1 : 0,
workers: runningInCi ? 2 : undefined,
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
use: {
baseURL,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
deviceScaleFactor: 1,
trace: "on-first-retry"
},
webServer: {
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL,
reuseExistingServer: false
},
+17 -374
View File
@@ -11,15 +11,9 @@ importers:
'@ai-sdk/react':
specifier: ^4.0.9
version: 4.0.34(react@19.2.7)(zod@3.25.76)
'@deck.gl/core':
specifier: ^9.3.7
version: 9.3.7
'@deck.gl/layers':
specifier: ^9.3.7
version: 9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))
'@deck.gl/mapbox':
specifier: ^9.3.7
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
'@fontsource-variable/noto-sans-sc':
specifier: 5.3.0
version: 5.3.0
'@radix-ui/react-accordion':
specifier: ^1.2.14
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -89,6 +83,9 @@ importers:
katex:
specifier: ^0.17.0
version: 0.17.0
keycloak-js:
specifier: 26.2.4
version: 26.2.4
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
@@ -189,9 +186,6 @@ importers:
msw:
specifier: ^2.6.8
version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
playwright:
specifier: ^1.61.1
version: 1.61.1
prettier:
specifier: ^3.4.2
version: 3.9.5
@@ -454,24 +448,6 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@deck.gl/core@9.3.7':
resolution: {integrity: sha512-l+YPm1I0Fwj+b7xsu8FvyDT5MGPgEPJe2cQJUpQNgG742b2goB4tyYpteUhNAOeVo1gmenvqv36ZJGNsGxipJA==}
'@deck.gl/layers@9.3.7':
resolution: {integrity: sha512-T2CnkEU1QEqmusrSdot7+8Nl09gzntG5DvhaogyBbTjmABhTh4rUnS6dLOjf5/ju3aJ+Q58/FvDzgBBcK4V7kg==}
peerDependencies:
'@deck.gl/core': ~9.3.0
'@loaders.gl/core': ^4.4.3
'@luma.gl/core': ~9.3.3
'@luma.gl/engine': ~9.3.3
'@deck.gl/mapbox@9.3.7':
resolution: {integrity: sha512-hamgwBS2BbB8DHOjYWwPMrFki+KhcedjoHv/9cUpQHnjfXUswzmyhAURuyowO/tAVJb3vF+CmMzaDVUTlI40Sw==}
peerDependencies:
'@deck.gl/core': ~9.3.0
'@luma.gl/core': ~9.3.3
'@math.gl/web-mercator': ^4.1.0
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -681,6 +657,9 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@fontsource-variable/noto-sans-sc@5.3.0':
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -758,49 +737,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@loaders.gl/core@4.4.3':
resolution: {integrity: sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==}
'@loaders.gl/images@4.4.3':
resolution: {integrity: sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@loaders.gl/loader-utils@4.4.3':
resolution: {integrity: sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==}
'@loaders.gl/schema-utils@4.4.3':
resolution: {integrity: sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@loaders.gl/schema@4.4.3':
resolution: {integrity: sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==}
'@loaders.gl/worker-utils@4.4.3':
resolution: {integrity: sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==}
peerDependencies:
'@loaders.gl/core': ~4.4.0
'@luma.gl/core@9.3.6':
resolution: {integrity: sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==}
'@luma.gl/engine@9.3.6':
resolution: {integrity: sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@luma.gl/shadertools': ~9.3.0
'@luma.gl/shadertools@9.3.6':
resolution: {integrity: sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@luma.gl/webgl@9.3.6':
resolution: {integrity: sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==}
peerDependencies:
'@luma.gl/core': ~9.3.0
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -829,21 +765,6 @@ packages:
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
hasBin: true
'@math.gl/core@4.1.0':
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
'@math.gl/polygon@4.1.0':
resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==}
'@math.gl/sun@4.1.0':
resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==}
'@math.gl/types@4.1.0':
resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==}
'@math.gl/web-mercator@4.1.0':
resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
@@ -871,15 +792,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@probe.gl/env@4.1.1':
resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==}
'@probe.gl/log@4.1.1':
resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==}
'@probe.gl/stats@4.1.1':
resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==}
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -1708,12 +1620,6 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/command-line-usage@5.0.4':
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1849,9 +1755,6 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
@@ -2084,10 +1987,6 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
apache-arrow@21.1.0:
resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==}
hasBin: true
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2102,10 +2001,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
array-back@6.2.3:
resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==}
engines: {node: '>=12.17'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2150,10 +2045,6 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
engines: {node: '>=12'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2205,19 +2096,6 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
command-line-args@6.0.2:
resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==}
engines: {node: '>=12.20'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
command-line-usage@7.0.4:
resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==}
engines: {node: '>=12.20.0'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -2475,9 +2353,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
earcut@2.2.4:
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
earcut@3.2.3:
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
@@ -2660,15 +2535,6 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
find-replace@5.0.2:
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
engines: {node: '>=14'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2677,9 +2543,6 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
@@ -2968,10 +2831,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-bignum@0.0.3:
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
engines: {node: '>=0.8'}
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -3003,6 +2862,9 @@ packages:
kdbush@4.1.0:
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
keycloak-js@26.2.4:
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3104,9 +2966,6 @@ packages:
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3355,9 +3214,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mjolnir.js@3.0.1:
resolution: {integrity: sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ==}
motion-dom@12.42.2:
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
@@ -3799,10 +3655,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -3897,10 +3749,6 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -4114,10 +3962,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4485,51 +4329,6 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@deck.gl/core@9.3.7':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
'@luma.gl/core': 9.3.6
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@luma.gl/webgl': 9.3.6(@luma.gl/core@9.3.6)
'@math.gl/core': 4.1.0
'@math.gl/sun': 4.1.0
'@math.gl/types': 4.1.0
'@math.gl/web-mercator': 4.1.0
'@probe.gl/env': 4.1.1
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@types/offscreencanvas': 2019.7.3
gl-matrix: 3.4.4
mjolnir.js: 3.0.1
transitivePeerDependencies:
- '@75lb/nature'
'@deck.gl/layers@9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))':
dependencies:
'@deck.gl/core': 9.3.7
'@loaders.gl/core': 4.4.3
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/schema': 4.4.3
'@luma.gl/core': 9.3.6
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@mapbox/tiny-sdf': 2.2.0
'@math.gl/core': 4.1.0
'@math.gl/polygon': 4.1.0
'@math.gl/web-mercator': 4.1.0
'@types/geojson': 7946.0.16
earcut: 2.2.4
transitivePeerDependencies:
- '@75lb/nature'
'@deck.gl/mapbox@9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)':
dependencies:
'@deck.gl/core': 9.3.7
'@luma.gl/core': 9.3.6
'@math.gl/web-mercator': 4.1.0
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -4671,6 +4470,8 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@fontsource-variable/noto-sans-sc@5.3.0': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -4741,82 +4542,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@loaders.gl/core@4.4.3':
dependencies:
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/schema': 4.4.3
'@loaders.gl/schema-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@probe.gl/log': 4.1.1
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/images@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/loader-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/schema': 4.4.3
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
transitivePeerDependencies:
- '@75lb/nature'
- '@loaders.gl/core'
'@loaders.gl/schema-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@loaders.gl/schema': 4.4.3
'@types/geojson': 7946.0.16
apache-arrow: 21.1.0
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/schema@4.4.3':
dependencies:
'@types/geojson': 7946.0.16
apache-arrow: 21.1.0
transitivePeerDependencies:
- '@75lb/nature'
'@loaders.gl/worker-utils@4.4.3(@loaders.gl/core@4.4.3)':
dependencies:
'@loaders.gl/core': 4.4.3
'@luma.gl/core@9.3.6':
dependencies:
'@math.gl/types': 4.1.0
'@probe.gl/env': 4.1.1
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@types/offscreencanvas': 2019.7.3
'@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))':
dependencies:
'@luma.gl/core': 9.3.6
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
'@math.gl/core': 4.1.0
'@math.gl/types': 4.1.0
'@probe.gl/log': 4.1.1
'@probe.gl/stats': 4.1.1
'@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)':
dependencies:
'@luma.gl/core': 9.3.6
'@math.gl/core': 4.1.0
'@math.gl/types': 4.1.0
'@luma.gl/webgl@9.3.6(@luma.gl/core@9.3.6)':
dependencies:
'@luma.gl/core': 9.3.6
'@math.gl/types': 4.1.0
'@probe.gl/env': 4.1.1
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -4846,22 +4571,6 @@ snapshots:
rw: 1.3.3
tinyqueue: 3.0.0
'@math.gl/core@4.1.0':
dependencies:
'@math.gl/types': 4.1.0
'@math.gl/polygon@4.1.0':
dependencies:
'@math.gl/core': 4.1.0
'@math.gl/sun@4.1.0': {}
'@math.gl/types@4.1.0': {}
'@math.gl/web-mercator@4.1.0':
dependencies:
'@math.gl/core': 4.1.0
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -4894,14 +4603,6 @@ snapshots:
dependencies:
playwright: 1.61.1
'@probe.gl/env@4.1.1': {}
'@probe.gl/log@4.1.1':
dependencies:
'@probe.gl/env': 4.1.1
'@probe.gl/stats@4.1.1': {}
'@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {}
@@ -5542,6 +5243,7 @@ snapshots:
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
optional: true
'@swc/types@0.1.27':
dependencies:
@@ -5656,10 +5358,6 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/command-line-args@5.2.3': {}
'@types/command-line-usage@5.0.4': {}
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@@ -5821,8 +5519,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/offscreencanvas@2019.7.3': {}
'@types/pbf@3.0.5': {}
'@types/react-dom@19.2.3(@types/react@19.2.17)':
@@ -6001,20 +5697,6 @@ snapshots:
ansi-styles@5.2.0: {}
apache-arrow@21.1.0:
dependencies:
'@swc/helpers': 0.5.23
'@types/command-line-args': 5.2.3
'@types/command-line-usage': 5.0.4
'@types/node': 24.13.3
command-line-args: 6.0.2
command-line-usage: 7.0.4
flatbuffers: 25.9.23
json-bignum: 0.0.3
tslib: 2.8.1
transitivePeerDependencies:
- '@75lb/nature'
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -6027,8 +5709,6 @@ snapshots:
aria-query@5.3.2: {}
array-back@6.2.3: {}
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
@@ -6065,10 +5745,6 @@ snapshots:
chai@6.2.2: {}
chalk-template@0.4.0:
dependencies:
chalk: 4.1.2
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -6120,20 +5796,6 @@ snapshots:
comma-separated-tokens@2.0.3: {}
command-line-args@6.0.2:
dependencies:
array-back: 6.2.3
find-replace: 5.0.2
lodash.camelcase: 4.3.0
typical: 7.3.0
command-line-usage@7.0.4:
dependencies:
array-back: 6.2.3
chalk-template: 0.4.0
table-layout: 4.1.1
typical: 7.3.0
commander@14.0.3: {}
commander@7.2.0: {}
@@ -6402,8 +6064,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
earcut@2.2.4: {}
earcut@3.2.3: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
@@ -6615,8 +6275,6 @@ snapshots:
dependencies:
flat-cache: 4.0.1
find-replace@5.0.2: {}
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -6627,8 +6285,6 @@ snapshots:
flatted: 3.4.2
keyv: 4.5.4
flatbuffers@25.9.23: {}
flatted@3.4.2: {}
form-data@4.0.6:
@@ -6973,8 +6629,6 @@ snapshots:
jsesc@3.1.0: {}
json-bignum@0.0.3: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -6997,6 +6651,8 @@ snapshots:
kdbush@4.1.0: {}
keycloak-js@26.2.4: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -7069,8 +6725,6 @@ snapshots:
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.merge@4.6.2: {}
longest-streak@3.1.0: {}
@@ -7586,8 +7240,6 @@ snapshots:
minimist@1.2.8: {}
mjolnir.js@3.0.1: {}
motion-dom@12.42.2:
dependencies:
motion-utils: 12.39.0
@@ -8091,11 +7743,6 @@ snapshots:
symbol-tree@3.2.4: {}
table-layout@4.1.1:
dependencies:
array-back: 6.2.3
wordwrapjs: 5.1.1
tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {}
@@ -8186,8 +7833,6 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
typical@7.3.0: {}
undici-types@7.18.2: {}
unified@11.0.5:
@@ -8369,8 +8014,6 @@ snapshots:
word-wrap@1.2.5: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
+5 -1
View File
@@ -1 +1,5 @@
globalThis.__TJWATER_CONFIG__ = {};
globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
};
+35
View File
@@ -0,0 +1,35 @@
import { readdir, readFile } from "node:fs/promises";
import { stdout } from "node:process";
import { fileURLToPath, URL } from "node:url";
import path from "node:path";
const assetsDirectory = fileURLToPath(new URL("../dist/assets/", import.meta.url));
const assetNames = await readdir(assetsDirectory);
const stylesheetName = assetNames.find((name) => /^index-.*\.css$/.test(name));
if (!stylesheetName) {
throw new Error("Production CSS verification failed: index stylesheet was not emitted.");
}
const stylesheet = await readFile(path.join(assetsDirectory, stylesheetName), "utf8");
const selector = ".agent-panel-shell.acrylic-panel";
const selectorOffset = stylesheet.indexOf(selector);
const ruleStart = stylesheet.indexOf("{", selectorOffset);
const ruleEnd = stylesheet.indexOf("}", ruleStart);
if (selectorOffset < 0 || ruleStart < 0 || ruleEnd < 0) {
throw new Error(`Production CSS verification failed: ${selector} rule is missing.`);
}
const declarations = stylesheet
.slice(ruleStart + 1, ruleEnd)
.split(";")
.map((declaration) => declaration.trim());
if (!declarations.some((declaration) => declaration.startsWith("backdrop-filter:"))) {
throw new Error(
"Production CSS verification failed: the standard backdrop-filter declaration was removed."
);
}
stdout.write(`Verified standard backdrop-filter in ${stylesheetName}.\n`);
+15 -7
View File
@@ -80,7 +80,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 +100,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 +112,18 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const navigationMaterial = await navigation.evaluate(
(element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
}
);
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
expect(commandShadow).not.toContain("0px 1px 3px 0px");
expect(navigationMaterial.backdropFilter).toBe("none");
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
});
+7 -2
View File
@@ -1,11 +1,16 @@
import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench";
import type { Authentication } from "@/shared/auth/keycloak-auth";
import { AppProviders } from "./providers";
export function App() {
export function App({ authentication }: { authentication: Authentication }) {
return (
<AppProviders>
<MapWorkbenchPage />
<MapWorkbenchPage
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
/>
<MapToaster />
</AppProviders>
);
+26
View File
@@ -0,0 +1,26 @@
import { Droplets, RefreshCw } from "lucide-react";
import { Button } from "@/shared/ui/button";
export function AuthStartupScreen({ error }: { error?: boolean }) {
return (
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
<Droplets size={22} aria-hidden="true" />
</span>
<h1 className="mt-4 text-lg font-semibold">
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
</p>
{error ? (
<Button className="mt-5" onClick={() => window.location.reload()}>
<RefreshCw size={15} aria-hidden="true" />
重新连接
</Button>
) : null}
</section>
</main>
);
}
+17
View File
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
);
});
it("adds the current Keycloak access token without dropping request headers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
expect(getAccessToken).toHaveBeenCalledOnce();
const init = fetchMock.mock.calls[0]?.[1];
if (!init) throw new Error("Expected Agent request init");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
+63 -79
View File
@@ -1,4 +1,5 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
@@ -112,19 +113,47 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
path,
init,
options.getAccessToken
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
};
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
return requestJson<unknown>("/frontend-action-registry");
},
async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
const payload = await requestJson<unknown>("/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
return requestJson<unknown>("/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
return requestJson<unknown>(
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
await requestJson<unknown>("/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
init?: RequestInit,
getAccessToken?: AccessTokenProvider
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -359,6 +337,12 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
@@ -1,7 +1,6 @@
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/shared/ui/cn";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
@@ -21,8 +20,7 @@ export function AgentCollapsedRail({
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
)}
>
<button
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
@@ -1,6 +1,7 @@
import {
Activity,
CheckCircle2,
Clock3,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
@@ -15,10 +16,11 @@ import {
Shield,
ShieldCheck,
Square,
Volume2
Volume2,
X
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
@@ -47,6 +49,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
@@ -69,6 +72,7 @@ import {
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
@@ -77,7 +81,17 @@ import type {
AgentUiResult
} from "../types";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
export type AgentWorkspaceContextItem = {
id: string;
label: string;
value: string;
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
@@ -85,6 +99,7 @@ type AgentCommandPanelProps = {
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
@@ -92,6 +107,7 @@ type AgentCommandPanelProps = {
selectedModel?: string;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
workspaceContext?: AgentWorkspaceContextItem[];
onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
@@ -127,6 +143,7 @@ const AGENT_CAPABILITIES = [
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
personaState,
sessionTitle = "新对话",
@@ -134,6 +151,7 @@ export function AgentCommandPanel({
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
messages = [],
streamRenderState = {},
@@ -141,6 +159,7 @@ export function AgentCommandPanel({
selectedModel = "",
approvalMode = "request",
uiResults = [],
workspaceContext = [],
onCollapse,
onRefreshHistory,
onStartNewSession,
@@ -159,6 +178,12 @@ export function AgentCommandPanel({
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const historyPanelId = useId();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const trimmedPrompt = prompt.trim();
const {
speechState,
@@ -194,6 +219,46 @@ export function AgentCommandPanel({
}
}, [historyOpen, onRefreshHistory]);
useEffect(() => {
if (!historyOpen) {
return;
}
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (
historyPanelRef.current?.contains(target) ||
historyTriggerRef.current?.contains(target)
) {
return;
}
setHistoryOpen(false);
};
const closeHistoryOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
setHistoryOpen(false);
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
};
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.addEventListener("keydown", closeHistoryOnEscape);
return () => {
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
document.removeEventListener("keydown", closeHistoryOnEscape);
};
}, [historyOpen]);
useEffect(() => {
stopSpeech();
}, [activeSessionId, stopSpeech]);
@@ -213,21 +278,62 @@ export function AgentCommandPanel({
setPrompt(nextPrompt);
});
};
const historyPanel = (
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
);
const operationalBrief = (
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
);
return (
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
"acrylic-panel border",
"rounded-2xl",
presentation === "desktop-dock"
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
: desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent",
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header">
<Agent
className={cn(
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
floatingPresentation && "gap-1 overflow-visible p-1"
)}
>
<div className="relative z-30 h-16 shrink-0">
<header
className={cn(
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
floatingPresentation && "agent-panel-integrated-header",
presentation === "mobile-sheet" && "agent-panel-header-mobile",
historyOpen && "agent-panel-header-expanded"
)}
>
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
@@ -237,15 +343,17 @@ export function AgentCommandPanel({
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
对话主题
</p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
getAgentConnectionClassName(statusLabel)
)}
<StatusBadge
tone={agentConnectionMetadata[connectionStatus].tone}
icon={
<StatusDot
tone={agentConnectionMetadata[connectionStatus].tone}
activity={connectionStatus === "connecting" ? "live" : "static"}
/>
{getAgentConnectionLabel(statusLabel)}
</span>
}
>
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
@@ -265,86 +373,143 @@ export function AgentCommandPanel({
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
ref={historyTriggerRef}
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
aria-expanded={historyOpen}
aria-controls={historyPanelId}
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<History size={17} aria-hidden="true" />
</button>
{presentation === "mobile-sheet" ? (
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<X size={17} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
)}
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
ref={historyPanelRef}
key="history"
id={historyPanelId}
role="region"
aria-label="Agent 历史记录"
className="agent-panel-floating-acrylic agent-panel-history-extension"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
{historyPanel}
</motion.div>
) : null}
</AnimatePresence>
</header>
</div>
{workspaceContext.length > 0 ? (
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
{workspaceContext.map((item) => (
<span
key={item.id}
className={cn(
"shrink-0 rounded-md px-2 py-1",
item.tone === "info" && "bg-blue-50 text-blue-700",
item.tone === "warning" && "bg-amber-50 text-amber-700",
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
)}
title={`${item.label}:${item.value}`}
>
{item.label} · <strong className="font-semibold">{item.value}</strong>
</span>
))}
</div>
) : null}
{floatingPresentation ? (
<AnimatePresence initial={false}>
{hasConversation && !historyOpen ? (
<motion.div
key="operational-brief"
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
) : (
<AnimatePresence initial={false}>
{hasConversation ? (
<motion.div
key="operational-brief"
aria-hidden={historyOpen}
className={cn(
"space-y-2 px-3 pb-3",
historyOpen && "invisible pointer-events-none"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
{operationalBrief}
</motion.div>
) : null}
</AnimatePresence>
)}
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<Conversation
className={cn(
"agent-panel-conversation min-h-0",
floatingPresentation && "agent-panel-conversation-canvas"
)}
>
<ConversationContent
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
className={cn(
"gap-4 p-3",
messages.length === 0 && "min-h-full",
floatingPresentation && "agent-panel-conversation-content",
floatingPresentation &&
hasConversation &&
"agent-panel-conversation-content-has-brief",
floatingPresentation &&
!hasConversation &&
"agent-panel-conversation-content-empty"
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
@@ -373,7 +538,7 @@ export function AgentCommandPanel({
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
</AnimatePresence>
@@ -384,14 +549,32 @@ export function AgentCommandPanel({
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
/>
<ConversationScrollButton
className={cn(
"agent-panel-floating-acrylic agent-conversation-scroll-button",
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
)}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<div
className={cn(
"agent-panel-band relative",
floatingPresentation
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-slate-200 p-3"
)}
>
{!hasConversation ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
)}
>
<Suggestions
aria-label="推荐问题"
role="group"
@@ -411,7 +594,7 @@ export function AgentCommandPanel({
</div>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
@@ -423,7 +606,7 @@ export function AgentCommandPanel({
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
@@ -435,7 +618,7 @@ export function AgentCommandPanel({
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
@@ -458,7 +641,13 @@ export function AgentCommandPanel({
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
{streaming ? undefined : (
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
@@ -497,29 +686,38 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
"border-transparent bg-transparent text-blue-600 shadow-none",
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
)}
disabled={disabled}
onClick={onClick}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
@@ -539,7 +737,7 @@ function VoiceInputButton({
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-red-500"
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
@@ -598,7 +796,8 @@ function ApprovalModeSelect({
<Button
type="button"
variant="outline"
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -639,17 +838,24 @@ function ApprovalModeSelect({
<DropdownMenuItem
className={cn(
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
</span>
{!requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -659,29 +865,36 @@ function ApprovalModeSelect({
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastMessageRevision: string;
lastUserMessageId: string | null;
scrollRequestId: number;
streaming: boolean;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId
scrollRequestId,
streaming
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
}) {
const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const lastUserMessageId = getLastUserMessageId(messages);
const lastMessageRevision = getLastMessageRevision(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastMessageRevision,
lastUserMessageId,
scrollRequestId
scrollRequestId,
streaming
}),
[activeSessionId, lastUserMessageId, scrollRequestId]
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
);
useEffect(() => {
@@ -693,7 +906,10 @@ function AgentConversationScrollManager({
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) &&
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
(snapshot.streaming &&
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
previousSnapshot.streaming !== snapshot.streaming;
if (!forceScroll) {
return;
@@ -713,6 +929,19 @@ function AgentConversationScrollManager({
return null;
}
function getLastMessageRevision(messages: AgentChatMessage[]) {
const lastMessage = messages.at(-1);
if (!lastMessage) {
return "";
}
const progressRevision =
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
@@ -746,7 +975,8 @@ function AgentModelSelect({
<Button
type="button"
variant="outline"
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
size="sm"
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
@@ -801,13 +1031,11 @@ function AgentModelSelect({
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
@@ -875,50 +1103,66 @@ function AgentReadyMark() {
);
}
function AgentEmptyState() {
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
className="mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-4 py-5">
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
<div className="flex items-center gap-4">
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
<div
className={cn(
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
)}
>
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-900"
className={cn(
"text-balance font-semibold text-slate-950",
compact ? "text-base leading-6" : "text-lg leading-7"
)}
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
<p
className={cn(
"text-pretty text-left text-sm leading-6 text-[#334155]",
compact ? "mt-3" : "mt-5"
)}
>
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
<ul
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
className={cn(
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={cn(
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-200/70",
index < 2 && "border-b border-slate-200/70"
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-300/80",
index < 2 && "border-b border-slate-300/80"
)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
@@ -980,9 +1224,10 @@ function BackendMessageList({
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "user" &&
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
)}
>
<div className="space-y-3">
@@ -1168,21 +1413,16 @@ function AgentSpeechMessage({
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
@@ -1270,20 +1510,21 @@ function SpeechIconButton({
children: ReactNode;
}) {
return (
<button
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="icon-sm"
variant="ghost"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
tone === "primary" && "text-blue-600",
tone === "danger" && "text-red-600"
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</button>
</Button>
);
}
@@ -1,8 +1,10 @@
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
@@ -73,30 +75,35 @@ export function AgentHistoryPanel({
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950">历史记录</p>
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
<p className="mt-0.5 text-xs text-slate-500">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
</div>
<button
<Button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
size="icon"
variant="ghost"
disabled={loading && sessions.length === 0}
loading={loading && sessions.length > 0}
loadingLabel="正在刷新 Agent 历史记录"
onClick={() => void Promise.resolve(onRefresh?.())}
>
{loading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={15} aria-hidden="true" />
)}
</button>
</Button>
</div>
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
<motion.div
className="max-h-56 space-y-1 overflow-y-auto pr-1"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
initial="initial"
animate="animate"
exit="exit"
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
key={session.id}
layout
className={cn(
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing
? "agent-history-item-active border border-blue-200"
: "border border-transparent"
)}
initial="initial"
animate="animate"
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
<motion.div
key="confirm-delete"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<span
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
title={session.title}
>
删除“{session.title}”?
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
>
<Input
autoFocus
className="h-8 rounded-lg bg-white text-xs"
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
@@ -168,7 +187,7 @@ export function AgentHistoryPanel({
<motion.button
key="session-summary"
type="button"
className="min-w-0 text-left"
className="agent-history-session-trigger min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
@@ -176,7 +195,10 @@ export function AgentHistoryPanel({
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
<span
className="block truncate text-xs font-semibold text-slate-900"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
@@ -185,79 +207,89 @@ export function AgentHistoryPanel({
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-1">
<span className="flex items-center gap-2">
{session.runStatus === "running" || session.isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<StatusDot tone="info" activity="live" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
<Loader2
size={13}
className="animate-spin text-blue-600"
aria-hidden="true"
/>
) : null}
{active && !editing && !confirmingDelete ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
当前
</span>
<StatusBadge tone="info">当前</StatusBadge>
) : null}
{confirmingDelete ? (
<>
<button
<Button
type="button"
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
disabled={deleting}
variant="destructive"
size="sm"
loading={deleting}
loadingLabel="正在删除对话"
onClick={() => confirmDelete(session.id)}
>
确认
</button>
<button
</Button>
<Button
type="button"
aria-label="取消删除"
title="取消删除"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : editing ? (
<>
<button
<Button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
disabled={saving}
size="icon-sm"
loading={saving}
loadingLabel="正在保存对话主题"
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="取消重命名"
title="取消重命名"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</button>
</Button>
</>
) : (
<>
<button
<Button
type="button"
aria-label="重命名对话"
title="重命名对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
size="icon-sm"
variant="ghost"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label="删除对话"
title="删除对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
size="icon-sm"
variant="ghost"
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
@@ -265,7 +297,7 @@ export function AgentHistoryPanel({
}}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</Button>
</>
)}
</span>
@@ -273,7 +305,14 @@ export function AgentHistoryPanel({
);
})
) : (
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
暂无历史记录
</motion.div>
)}
@@ -0,0 +1,90 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentMessageDetails } from "./agent-message-details";
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
afterEach(cleanup);
describe("AgentMessageDetails question answers", () => {
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析范围",
question: "你想分析哪一部分?",
options: [
{ label: "雨污混接分析", description: "检查混接问题" },
{ label: "水质异常分析", description: "检查水质指标" }
]
},
onReplyQuestion
);
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["检查高峰时段压力"]]
);
});
it("combines selected options with a custom answer for a multiple-choice question", async () => {
const user = userEvent.setup();
const onReplyQuestion = vi.fn();
renderQuestion(
{
header: "分析内容",
question: "需要包含哪些内容?",
options: [
{ label: "压力", description: "分析节点压力" },
{ label: "流量", description: "分析管段流量" }
],
multiple: true,
custom: true
},
onReplyQuestion
);
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
await user.click(screen.getByRole("button", { name: "提交回答" }));
expect(onReplyQuestion).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "question-1" }),
[["压力", "补充水龄"]]
);
});
});
function renderQuestion(
question: AgentQuestionInfo,
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
) {
render(
<AgentMessageDetails
message={{
id: "assistant-1",
role: "assistant",
content: "需要补充信息",
questions: [
{
requestId: "question-1",
sessionId: "session-1",
questions: [question],
createdAt: 100,
status: "pending"
}
]
}}
onReplyQuestion={onReplyQuestion}
/>
);
}
@@ -10,9 +10,16 @@ import {
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { useId, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusActivity,
type StatusTone
} from "@/shared/ui/status";
import {
agentEase,
agentLayoutTransition,
@@ -37,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
const STREAMING_PROGRESS_LIMIT = 3;
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
type AgentStatusMeta = {
label: string;
tone: StatusTone;
activity?: StatusActivity;
};
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
label: "已完成",
tone: "success"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
tone: "danger"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
tone: "success"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
tone: "info",
activity: "live"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
tone: "neutral"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
tone: "neutral"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
approved_always: { label: "已始终允许", tone: "success" },
approved_once: { label: "已允许一次", tone: "success" },
rejected: { label: "已拒绝", tone: "danger" },
aborted: { label: "已中止", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待确认", tone: "warning" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" },
submitting: { label: "提交中", tone: "info", activity: "live" },
pending: { label: "待回答", tone: "info" }
};
export function AgentMessageDetails({
@@ -198,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const listId = useId();
const compact = running && !expanded;
const visibleProgress = expanded
? progress
@@ -207,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
{running ? (
<StatusDot tone="info" activity="live" aria-hidden="true" />
) : null}
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
<span className="font-mono tabular-nums text-slate-600">
{expanded || !running
? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
<span className="px-0.5 text-slate-300">/</span>
{progress.length}
</span>
</span>
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
className={cn(
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
expanded && "text-blue-500"
)}
aria-controls={listId}
aria-expanded={expanded}
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
data-state={expanded ? "open" : "closed"}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span>执行进度</span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length} 条`
: running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
: `共 ${progress.length} 条`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
className={cn("transition-transform duration-150", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
</div>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
id={listId}
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
<motion.span
aria-hidden="true"
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
progressStatusMeta[item.status].dotClassName
)}
animate={
item.status === "running"
? { opacity: [0.45, 1, 0.45], scale: 1 }
: item.status === "error"
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
: { opacity: 1, scale: 1 }
}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: item.status === "error"
? { duration: 0.28, ease: agentEase }
: { duration: 0.14 }
}
<StatusDot
tone={progressStatusMeta[item.status].tone}
activity={progressStatusMeta[item.status].activity}
size="md"
className="mt-1"
/>
<span className="min-w-0">
<span
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
progressStatusMeta[item.status].className
"min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running"
? "text-blue-600"
: [
`status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]"
]
)}
initial={{ opacity: 0 }}
animate={
item.status === "error"
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
: { opacity: 1, x: 0 }
}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={
item.status === "error"
? { duration: 0.24, ease: agentEase }
: { duration: 0.14 }
}
transition={{ duration: 0.14 }}
>
{progressStatusMeta[item.status].label}
</motion.span>
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
<SemanticStatusIcon
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
shape="circle"
size="xs"
className="mt-0.5"
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
</SemanticStatusIcon>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
<StatusBadge
tone={todoStatusMeta[todo.status].tone}
activity={todoStatusMeta[todo.status].activity}
>
{todoStatusMeta[todo.status].label}
</span>
</StatusBadge>
</motion.li>
))}
</AnimatePresence>
@@ -376,7 +385,7 @@ function PermissionList({
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
@@ -424,18 +433,22 @@ function PermissionRequestCard({
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
<StatusBadge
tone={permissionStatusMeta[permission.status].tone}
activity={permissionStatusMeta[permission.status].activity}
className="self-start"
>
{permissionStatusMeta[permission.status].label}
</span>
</StatusBadge>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -445,7 +458,7 @@ function PermissionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
@@ -457,7 +470,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
@@ -469,7 +482,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
disabled={disabled}
onClick={() => reply("reject")}
>
@@ -547,9 +560,12 @@ function QuestionRequestCard({
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
<StatusBadge
tone={questionStatusMeta[request.status].tone}
activity={questionStatusMeta[request.status].activity}
>
{questionStatusMeta[request.status].label}
</span>
</StatusBadge>
</div>
<div className="space-y-3">
@@ -558,7 +574,7 @@ function QuestionRequestCard({
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
@@ -568,7 +584,7 @@ function QuestionRequestCard({
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
@@ -580,7 +596,7 @@ function QuestionRequestCard({
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
className="mt-2 flex flex-wrap items-center gap-2"
initial="initial"
animate="animate"
exit="exit"
@@ -590,7 +606,7 @@ function QuestionRequestCard({
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
className="h-8 px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
@@ -601,7 +617,7 @@ function QuestionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
@@ -617,6 +633,7 @@ function QuestionRequestCard({
type QuestionDraftAnswer = {
selected: string[];
customSelected: boolean;
custom: string;
};
@@ -633,8 +650,11 @@ function QuestionInput({
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const inputId = useId();
const name = `agent-question-${questionIndex}-${inputId}`;
const customEnabled = question.custom !== false;
const showCustomChoice = customEnabled && question.options.length > 0;
const showCustomInput = customEnabled && value.customSelected;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
@@ -646,21 +666,36 @@ function QuestionInput({
}
onChange({
...value,
selected: checked ? [label] : []
selected: checked ? [label] : [],
customSelected: false
});
};
const toggleCustom = (checked: boolean) => {
onChange({
...value,
selected: question.multiple ? value.selected : [],
customSelected: checked
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
{question.options.length || showCustomChoice ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
checked
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -679,12 +714,34 @@ function QuestionInput({
</label>
);
})}
{showCustomChoice ? (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
value.customSelected
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="h-3.5 w-3.5 accent-blue-600"
checked={value.customSelected}
disabled={disabled}
onChange={(event) => toggleCustom(event.currentTarget.checked)}
/>
<span className="font-semibold text-slate-700">自定义回答</span>
</label>
) : null}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
aria-label="自定义回答内容"
placeholder="输入自定义回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
@@ -695,16 +752,25 @@ function QuestionInput({
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
return request.questions.map((question, index) => {
const optionLabels = new Set(question.options.map((option) => option.label));
const savedAnswers = request.answers?.[index] ?? [];
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
const customEnabled = question.custom !== false;
return {
selected,
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
custom: customAnswers.join(";")
};
});
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
});
}
@@ -1,15 +1,9 @@
import {
ClipboardCheck,
Crosshair,
MapPinned,
Radio,
Route
} from "lucide-react";
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import type {
AgentChatMessage
} from "../types";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
import type { AgentChatMessage } from "../types";
export function AgentOperationalBrief({
messages,
@@ -26,52 +20,71 @@ export function AgentOperationalBrief({
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="flex items-start gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
{brief.task}
</p>
</div>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
<AgentBriefMetric
icon={<Radio size={13} aria-hidden="true" />}
label="证据"
value={brief.evidence}
/>
<AgentBriefMetric
icon={<Route size={13} aria-hidden="true" />}
label="计划"
value={brief.plan}
/>
<AgentBriefMetric
icon={<ClipboardCheck size={13} aria-hidden="true" />}
label="确认"
value={brief.confirmation}
/>
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
<Button
type="button"
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
预览影响
</button>
<button
</Button>
<Button
type="button"
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
variant="outline"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
检查证据
</button>
</Button>
</div>
</div>
);
}
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
function AgentBriefMetric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
);
}
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
function createOperationalBrief(
messages: AgentChatMessage[],
statusLabel: string,
streaming: boolean
) {
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
const questionItems = messages.flatMap((message) => message.questions ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations =
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
const pendingConfirmations = countPendingAgentInputs(messages);
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const plan =
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
const stateLabel =
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
plan,
confirmation,
stateLabel,
statusDotClassName,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
@@ -282,7 +283,7 @@ function TrustedPanelShell({
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
{title}
</div>
{children}
+7
View File
@@ -1,9 +1,15 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
AgentCommandPanelPresentation,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary,
AgentLoadedChatSession,
AgentSessionStreamEvent
@@ -55,6 +61,7 @@ export type {
AgentApprovalMode,
AgentChatMessage,
AgentChatProgress,
AgentConnectionStatus,
AgentInteractionToolRef,
AgentModelOption,
AgentPermissionReply,
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
import type { AgentChatMessage } from "./types";
describe("Agent message state", () => {
it("counts pending and failed operator inputs from one shared rule", () => {
const messages = [
{
id: "assistant-1",
role: "assistant",
content: "",
permissions: [
{ status: "pending" },
{ status: "approved_once" }
],
questions: [
{ status: "error" },
{ status: "answered" }
]
}
] as AgentChatMessage[];
expect(countPendingAgentInputs(messages)).toBe(2);
expect(hasPendingAgentInput(messages)).toBe(true);
expect(hasPendingAgentInput([])).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { AgentChatMessage } from "./types";
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
return messages.reduce((total, message) => {
const pendingPermissions =
message.permissions?.filter(
(permission) => permission.status === "pending" || permission.status === "error"
).length ?? 0;
const pendingQuestions =
message.questions?.filter(
(question) => question.status === "pending" || question.status === "error"
).length ?? 0;
return total + pendingPermissions + pendingQuestions;
}, 0);
}
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
return countPendingAgentInputs(messages) > 0;
}
+27
View File
@@ -94,6 +94,33 @@ describe("agent session state", () => {
});
});
it("preserves an actionable question id when its tool placeholder arrives later", () => {
const questions = upsertQuestion(undefined, {
session_id: "session-1",
request_id: "question-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 100
});
const withPlaceholder = upsertQuestion(questions, {
session_id: "session-1",
request_id: "call-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 120
});
const rejected = applyQuestionResponse(withPlaceholder, {
request_id: "question-1",
rejected: true
});
expect(withPlaceholder).toHaveLength(1);
expect(rejected?.[0]).toMatchObject({
requestId: "question-1",
status: "rejected"
});
});
it("finalizes open assistant state after abort", () => {
const message: AgentChatMessage = {
id: "assistant-1",
+16 -3
View File
@@ -183,10 +183,23 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
next[index] = {
...next[index],
const current = next[index];
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
next[index] =
currentIsActionable && nextIsToolPlaceholder
? {
...current,
sessionId: nextItem.sessionId,
questions: nextItem.questions,
tool: nextItem.tool ?? current.tool,
createdAt: nextItem.createdAt
}
: {
...current,
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
status: current.status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
+2
View File
@@ -14,6 +14,8 @@ export type AgentStreamRenderMessageState = {
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
export type AgentConnectionStatus = "connecting" | "online" | "offline";
export type AgentChatProgress = {
id: string;
phase: string;
@@ -158,7 +158,7 @@ export function MapActionRow({
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
@@ -172,7 +172,7 @@ export function MapActionRow({
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
disabled && "active:translate-y-0 active:scale-100"
disabled && "shadow-none"
)}
>
<span
@@ -53,7 +53,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.active
? "border-blue-600 bg-blue-600 text-white shadow-md shadow-blue-600/20"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700",
@@ -85,7 +85,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
role="toolbar"
aria-label={label}
className={cn(
"pointer-events-auto inline-flex p-1",
"pointer-events-auto inline-flex gap-1 p-1",
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
className
@@ -105,14 +105,13 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
disabled={item.disabled}
onClick={item.onClick}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
item.active
? "bg-blue-600 text-white shadow-xs"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-blue-50 hover:text-blue-700",
isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)}
>
<Icon size={16} aria-hidden="true" />
@@ -61,7 +61,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
title={item.description}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]",
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
+2 -1
View File
@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusDot } from "@/shared/ui/status";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -66,7 +67,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
</span>
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
</li>
);
})}
@@ -1,3 +1,5 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
@@ -24,4 +26,4 @@ export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME =
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
buttonInteractionVariants({ tone: "neutral" });
@@ -117,7 +117,7 @@ export function MapMeasurePanel({
type="button"
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-7 text-xs font-semibold transition",
"h-10 text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
"absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
@@ -1,6 +1,11 @@
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -8,37 +13,13 @@ import {
} from "./map-control-styles";
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
const noticeTones = {
info: "info",
success: "success",
warning: "warning",
error: "danger",
loading: "info"
} satisfies Record<MapNoticeTone, StatusTone>;
export function MapNoticeContent({
title,
@@ -55,7 +36,11 @@ export function MapNoticeContent({
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
action={
actionLabel && onAction ? (
<MapNoticeAction label={actionLabel} onClick={onAction} />
) : null
}
/>
</MapNoticeFrame>
);
@@ -75,29 +60,31 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
function MapNoticeIcon({
tone,
icon: Icon
}: {
tone: MapNoticeTone;
icon: ReturnType<typeof getNoticeIcon>;
}) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
<SemanticStatusIcon
tone={noticeTones[tone]}
activity={tone === "loading" ? "loading" : "static"}
size="md"
className={cn("mt-0.5", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
<Icon size={17} />
</SemanticStatusIcon>
);
}
@@ -112,8 +99,12 @@ function MapNoticeBody({
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{title ? (
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
) : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
{message}
</span>
{action}
</span>
);
@@ -121,13 +112,15 @@ function MapNoticeBody({
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
<Button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
size="sm"
variant="outline"
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
</Button>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function MapToaster() {
classNames: {
toast: "group pointer-events-auto",
closeButton:
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
}
}}
/>
+6 -7
View File
@@ -44,10 +44,10 @@ export function MapToolbar({
<nav
aria-label={label}
className={cn(
"pointer-events-auto p-[3px]",
"pointer-events-auto p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
isVertical ? "flex w-12 flex-col" : "inline-flex",
className
)}
>
@@ -67,19 +67,18 @@ export function MapToolbar({
disabled={tool.disabled}
onClick={tool.onClick}
className={cn(
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
tool.active
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
)}
>
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
<span className="sr-only">{tool.label}</span>
{tool.badge ? (
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
<span className="status-tone-danger absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-[var(--status-mark)] px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
{tool.badge}
</span>
) : null}
+5 -6
View File
@@ -37,13 +37,13 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
role="group"
aria-label="地图缩放"
className={cn(
"pointer-events-auto flex w-[46px] flex-col gap-2",
"pointer-events-auto flex w-12 flex-col gap-2",
className
)}
>
<div
className={cn(
"flex flex-col p-[3px]",
"flex flex-col p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
@@ -65,7 +65,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
<div
className={cn(
"p-[3px]",
"p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
@@ -99,13 +99,12 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
disabled={disabled}
onClick={onClick}
className={cn(
"grid h-[38px] w-[38px] place-items-center transition duration-150",
"grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
"bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME,
disabled &&
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
)}
>
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
@@ -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 = {
@@ -33,21 +37,80 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
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: "demand", label: "需水量 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 查询暂不可用",
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 +131,49 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const numericProperties = useMemo(
() => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId
? getNumericFeatureProperties(detailFeature.properties)
: [],
[detailFeature, featureId, sourceId]
);
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
function selectStyleGroup(nextGroupId: LayerGroupId) {
const preset = LABEL_PRESETS[nextGroupId];
const saved = controllerState.valueLabels[nextGroupId];
setGroupId(nextGroupId);
setProperty(saved?.property ?? preset.property);
setPrecision(saved?.precision ?? preset.precision);
setUnit(saved?.unit ?? preset.unit);
setShowValueLabels(saved?.showValue ?? true);
setShowFeatureIds(saved?.showId ?? false);
setLabelSize(saved?.textSize ?? 12);
setLabelColor(saved?.textColor ?? MAP_STYLE_TOKENS.label.primary);
}
function useSelectedFeature() {
if (!detailFeature) return;
if (AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)) {
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId;
selectStyleGroup(nextGroupId);
const allowedProperties = new Set(
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
);
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
([name]) => allowedProperties.has(name)
);
if (firstNumeric) setProperty(firstNumeric[0]);
}
}
function applyDraft() {
function applyLayerStyle() {
let patch: LayerGroupStylePatch;
if (geometry === "line") {
patch = {
@@ -103,15 +189,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 +234,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 +258,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
<Field label="业务源">
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as AvailableWaterNetworkSourceId)} className={inputClassName}>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={sourceId}
onChange={(event) => {
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
className={inputClassName}
>
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
</Field>
<Field label="Feature ID">
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
<input
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到 Feature
</Button>
</div>
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.target ? (
<StatusText>
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
</StatusText>
) : null}
</PanelSection>
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
<PanelSection
title="全网水流"
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
</PanelSection>
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
<Field label="数值属性">
{numericProperties.length ? (
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
</select>
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
<Button
type="button"
disabled={sourceId !== "pipes" || !target.featureId}
onClick={() => setFlowPipeId(target.featureId)}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后自动显示全网 mock 流向</StatusText>
)}
</PanelSection>
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
>
<Field label="业务图层组">
<div className="relative">
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
<select
value={groupId}
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
className={cn(inputClassName, "appearance-none pr-8")}
>
{STYLE_GROUP_IDS.map((id) => (
<option key={id} value={id}>
{GROUP_LABELS[id]}
</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
<ChevronDown
size={14}
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
/>
</div>
</Field>
{geometry === "line" ? (
<>
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<Field label="线型">
<select
value={draft.dash}
onChange={(event) =>
setDraft((current) => ({ ...current, dash: event.target.value }))
}
className={inputClassName}
>
<option value="original">原始</option>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
</select>
</Field>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.iconMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<Field label="数值属性">
<select
value={property}
onChange={(event) => setProperty(event.target.value)}
className={inputClassName}
>
{LABEL_PRESETS[groupId].options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-slate-200/70 p-3">
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
<Button
type="button"
onClick={() => commands.resetAll()}
className="w-full text-xs"
variant="outline"
>
<RotateCcw size={14} />
重置全部
</Button>
</div>
</aside>
);
}
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
function PanelSection({
title,
description,
children
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
</div>
{children}
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
{children}
</label>
);
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
function ToggleField({
label,
checked,
onChange
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-blue-600"
/>
{label}
</label>
);
}
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
function ColorField({
label,
value,
onChange
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
/>
<span className="text-xs font-medium text-slate-600">{value}</span>
</div>
</Field>
);
}
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
function RangeField({
label,
min,
max,
step,
value,
onChange
}: {
label: string;
min: number;
max: number;
step: number;
value: number;
onChange: (value: number) => void;
}) {
return (
<Field label={`${label} · ${value}`}>
<input
aria-label={label}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-blue-600"
/>
</Field>
);
}
function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
>
{children}
</p>
);
}
const inputClassName =
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
@@ -0,0 +1,17 @@
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
export type MobileWorkbenchSheetSnap = "half" | "full";
export function getMobileWorkbenchSheetHeight(
snap: MobileWorkbenchSheetSnap,
viewportHeight: number
) {
if (snap === "half") {
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
}
return Math.max(
Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio),
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
describe("mobile workbench sheet heights", () => {
it("keeps the two deterministic snap heights", () => {
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
});
it("always leaves the configured map strip visible at full height", () => {
const viewportHeight = 667;
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
expect(viewportHeight - sheetHeight).toBe(68);
});
});
@@ -0,0 +1,198 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode
} from "react";
import { cn } from "@/shared/ui/cn";
import {
getMobileWorkbenchSheetHeight,
type MobileWorkbenchSheetSnap
} from "./mobile-workbench-sheet-layout";
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
type MobileWorkbenchSheetProps = {
label: string;
snap: MobileWorkbenchSheetSnap;
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
onClose: () => void;
children: ReactNode;
className?: string;
};
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
export function MobileWorkbenchSheet({
label,
snap,
onSnapChange,
onClose,
children,
className
}: MobileWorkbenchSheetProps) {
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
const draggedRef = useRef(false);
const [dragHeight, setDragHeight] = useState<number | null>(null);
const [viewportHeight, setViewportHeight] = useState(
typeof window === "undefined" ? 844 : window.innerHeight
);
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
useEffect(() => {
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
window.addEventListener("resize", updateViewportHeight);
window.visualViewport?.addEventListener("resize", updateViewportHeight);
return () => {
window.removeEventListener("resize", updateViewportHeight);
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
};
}, []);
useEffect(() => {
const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
pointerStartRef.current = {
y: event.clientY,
height: restingHeight
};
draggedRef.current = false;
event.currentTarget.setPointerCapture(event.pointerId);
}
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
const start = pointerStartRef.current;
if (!start) {
return;
}
const delta = start.y - event.clientY;
if (Math.abs(delta) > 4) {
draggedRef.current = true;
}
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
}
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
if (!pointerStartRef.current) {
return;
}
const finalHeight = dragHeight ?? pointerStartRef.current.height;
pointerStartRef.current = null;
setDragHeight(null);
event.currentTarget.releasePointerCapture(event.pointerId);
if (!draggedRef.current) {
return;
}
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
}
function handleClick() {
if (draggedRef.current) {
draggedRef.current = false;
return;
}
const currentIndex = SNAP_ORDER.indexOf(snap);
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowUp") {
event.preventDefault();
onSnapChange(getAdjacentSnap(snap, 1));
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
onSnapChange(getAdjacentSnap(snap, -1));
return;
}
if (event.key === "Home") {
event.preventDefault();
onSnapChange("half");
return;
}
if (event.key === "End") {
event.preventDefault();
onSnapChange("full");
}
}
return (
<section
aria-label={label}
data-snap={snap}
className={cn(
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
className
)}
style={{ height: dragHeight ?? restingHeight }}
>
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
<button
type="button"
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
className="group flex h-7 flex-1 touch-none items-center justify-center"
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
</button>
</div>
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
</section>
);
}
function getAdjacentSnap(
current: MobileWorkbenchSheetSnap,
direction: -1 | 1
): MobileWorkbenchSheetSnap {
const currentIndex = SNAP_ORDER.indexOf(current);
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
return SNAP_ORDER[nextIndex] ?? current;
}
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
return SNAP_ORDER.reduce((closest, candidate) => {
const candidateDistance = Math.abs(
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
);
const closestDistance = Math.abs(
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
);
return candidateDistance < closestDistance ? candidate : closest;
}, "half");
}
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
if (snap === "half") {
return "半屏档";
}
return "全屏档";
}
@@ -19,6 +19,11 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import { showMapNotice } from "@/features/map/core";
import {
getRunningWorkflowDefinition,
@@ -37,25 +42,26 @@ import {
formatDuration,
formatTime,
formatTimeRange,
getStatusActivity,
getStatusTone,
getRiskLabel,
getWorkOrderCurrentStage,
getWorkOrderStagePillClassName,
statusClassNames,
getWorkOrderStageActivity,
getWorkOrderStageTone,
statusLabels
} from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000;
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
normal: "border-slate-200 bg-slate-50 text-slate-600",
attention: "border-orange-200 bg-orange-50 text-orange-700",
critical: "border-red-200 bg-red-50 text-red-700"
};
const riskTones = {
normal: "success",
attention: "warning",
critical: "danger"
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
type IncidentOperation = {
target: string;
@@ -86,6 +92,7 @@ type ExecutionStep = {
title: string;
description: string;
status: ExecutionStepStatus;
statusLabel?: string;
meta?: ExecutionStepMeta[];
};
@@ -105,15 +112,21 @@ export function StatusPill({
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
return (
<span
className={cn(
STATUS_PILL_CLASS_NAME,
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
className
)}
<StatusBadge
tone={
isWorkOrder
? getWorkOrderStageTone(currentStage?.id)
: getStatusTone(condition.status)
}
activity={
isWorkOrder
? getWorkOrderStageActivity(currentStage?.id)
: getStatusActivity(condition.status)
}
className={className}
>
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
</span>
</StatusBadge>
);
}
@@ -133,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
<AnimatePresence initial={false} mode="wait">
<motion.div
key={detailKey}
className="min-h-0"
className="relative h-full min-h-0"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
@@ -198,7 +211,7 @@ function ConditionRecordDetailPanel({
<DecisionSummaryBlock
icon={ShieldAlert}
title="异常判断"
iconClassName="text-red-600"
iconClassName="status-tone-danger text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
/>
@@ -221,7 +234,7 @@ function ConditionRecordDetailPanel({
<DecisionSummaryBlock
icon={Lightbulb}
title="关注说明"
iconClassName="text-orange-600"
iconClassName="status-tone-warning text-[var(--status-foreground)]"
summary={insight.summary}
notes={insight.notes}
/>
@@ -274,7 +287,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
icon={CalendarClock}
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"}
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
/>
@@ -331,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
<section
className={cn(
NEUTRAL_SURFACE_CLASS_NAME,
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
)}
>
<div
data-testid="scheduled-condition-detail-scroll"
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
>
{children}
</div>
</section>
);
}
@@ -343,9 +366,7 @@ type DetailShellProps = {
function DetailShell({ children }: DetailShellProps) {
return (
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
{children}
</div>
<div className="overflow-hidden">{children}</div>
);
}
@@ -520,7 +541,9 @@ function IncidentWorkOrderPanel({
className={cn(
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
"rounded-xl",
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
submitted
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
: "bg-blue-600 text-white hover:bg-blue-700"
)}
>
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
@@ -543,9 +566,9 @@ function IncidentOperationList({
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionEyebrow icon={Wrench} label={operation.action} />
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
<StatusBadge tone={submitted ? "success" : "neutral"}>
{submitted ? "已通知" : operation.elementType}
</span>
</StatusBadge>
</div>
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
<div className="grid grid-cols-[56px_1fr] gap-2">
@@ -645,10 +668,9 @@ function ConditionStatusReport({
function LifecyclePill() {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
<CheckCircle2 size={12} aria-hidden="true" />
<span>完成</span>
</span>
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
完成
</StatusBadge>
);
}
@@ -657,23 +679,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
const label = getRiskLabel(riskLevel);
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
getRiskIndicatorClassName(riskLevel)
)}
<StatusBadge
tone={riskTones[riskLevel]}
icon={<Icon size={12} />}
aria-label={`风险等级:${label}`}
>
<Icon size={12} aria-hidden="true" />
<span>{label}</span>
</span>
{label}
</StatusBadge>
);
}
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
return riskIndicatorClassNames[riskLevel];
}
function ReportMetaTile({ label, value }: { label: string; value: string }) {
return (
<div className={REPORT_META_TILE_CLASS_NAME}>
@@ -709,7 +724,7 @@ function ReportSection({
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
return (
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
@@ -720,9 +735,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
</div>
</div>
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
<StatusBadge tone={riskTones[kpi.status]}>
{getRiskLabel(kpi.status)}
</span>
</StatusBadge>
</div>
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
{kpi.threshold || kpi.baseline ? (
@@ -735,18 +750,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
);
}
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
if (riskLevel === "critical") {
return "border-red-200/80";
}
if (riskLevel === "attention") {
return "border-orange-200/80";
}
return "border-slate-200/70";
}
function DecisionActionStrip({
icon: Icon,
title,
@@ -806,64 +809,36 @@ function SectionEyebrow({
);
}
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
if (tone === "completed") {
return "border-green-100 bg-green-50 text-green-700";
return "success";
}
if (tone === "confirmation") {
return "border-orange-100 bg-orange-50 text-orange-700";
return "warning";
}
if (tone === "active") {
return "border-blue-100 bg-blue-50 text-blue-700";
return "info";
}
return "border-slate-200 bg-slate-50 text-slate-600";
return "neutral";
}
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
if (status === "done") {
return "bg-green-500 text-white";
return "success";
}
if (status === "current") {
return "bg-blue-600 text-white";
return "info";
}
if (status === "confirmation") {
return "bg-orange-500 text-white";
return "warning";
}
return "bg-slate-100 text-slate-400";
}
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
if (status === "current") {
return "surface-reading border-blue-100";
}
if (status === "confirmation") {
return "surface-reading border-orange-100";
}
return "surface-reading border-slate-200/70";
}
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
if (status === "done") {
return "border-green-100 bg-green-50 text-green-700";
}
if (status === "current") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
if (status === "confirmation") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
return "border-slate-200 bg-white text-slate-500";
return "neutral";
}
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
@@ -931,7 +906,6 @@ function RunningWorkflowPanel({
return (
<TaskExecutionPanel
icon={Loader2}
title={workflow.title}
badge="执行中"
badgeTone="active"
@@ -987,7 +961,7 @@ function TaskExecutionPanel({
badgeTone = "pending",
children
}: {
icon: typeof Bot;
icon?: typeof Bot;
title: string;
badge: string;
summary?: string;
@@ -1000,10 +974,17 @@ function TaskExecutionPanel({
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
{Icon ? (
<SectionTitle icon={Icon} title={title} />
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
) : (
<span className="text-xs font-semibold">{title}</span>
)}
<StatusBadge
tone={getTaskExecutionBadgeTone(badgeTone)}
activity={badgeTone === "active" ? "live" : "static"}
>
{badge}
</span>
</StatusBadge>
</div>
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
{meta?.length ? (
@@ -1042,29 +1023,32 @@ function TaskExecutionStepRow({
{!isLast ? (
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
) : null}
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
<SemanticStatusIcon
tone={getTaskExecutionStepTone(step.status)}
activity={step.status === "current" ? "loading" : "static"}
shape="circle"
size="md"
className="relative z-10 ring-4 ring-white"
>
{step.status === "done" ? (
<CheckCircle2 size={14} aria-hidden="true" />
<CheckCircle2 size={14} />
) : step.status === "current" ? (
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
<Loader2 size={14} />
) : step.status === "confirmation" ? (
<AlertTriangle size={14} aria-hidden="true" />
<AlertTriangle size={14} />
) : (
<Clock3 size={14} aria-hidden="true" />
<Clock3 size={14} />
)}
</span>
</SemanticStatusIcon>
</span>
<span
className={cn(
"min-w-0 rounded-xl border px-2.5 py-2",
getTaskExecutionStepSurfaceClassName(step.status)
)}
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"
>
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
{getTaskExecutionStepStatusLabel(step.status)}
</span>
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
</StatusBadge>
</span>
{step.meta?.length ? (
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
@@ -1085,23 +1069,28 @@ function TaskExecutionStepRow({
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const duration = formatDuration(condition.durationMinutes, condition.status);
const currentStage = getWorkOrderCurrentStage(condition);
const steps = condition.stages.map((stage) => ({
const steps = condition.stages.map((stage) => {
const awaitingReply = stage.id === "reply" && stage.status === "current";
return {
id: stage.id,
title: stage.title,
status: stage.status,
status: awaitingReply ? ("confirmation" as const) : stage.status,
statusLabel: awaitingReply ? "待复令" : undefined,
description: stage.description,
meta: [
{ label: "责任人", value: stage.owner },
{ label: "时间", value: stage.timeLabel }
]
}));
};
});
return (
<TaskExecutionPanel
icon={ClipboardList}
title="任务执行"
badge={currentStage ? currentStage.title : condition.code}
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
badgeTone={currentStage?.id === "reply" ? "confirmation" : currentStage?.id === "execution" ? "active" : "pending"}
summary={condition.detail ?? condition.summary}
meta={[
{ label: "来源", value: condition.source },
@@ -1152,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
{index + 1}
</span>
<span className="line-clamp-1">{item}</span>
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
<StatusBadge tone="info" activity="loading" className="mt-0.5">
采集中
</StatusBadge>
</li>
))
) : (
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getStatusActivity,
getStatusTone,
getWorkOrderStageActivity,
getWorkOrderStageTone
} from "./scheduled-condition-feed-utils";
describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => {
expect({
pending: getStatusTone("pending"),
running: getStatusTone("running"),
awaitingReply: getStatusTone("awaiting_reply"),
completed: getStatusTone("completed"),
warning: getStatusTone("warning"),
error: getStatusTone("error")
}).toEqual({
pending: "neutral",
running: "info",
awaitingReply: "warning",
completed: "success",
warning: "warning",
error: "danger"
});
expect(getStatusActivity("running")).toBe("loading");
expect(getStatusActivity("awaiting_reply")).toBe("static");
});
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
expect({
dispatch: getWorkOrderStageTone("dispatch"),
execution: getWorkOrderStageTone("execution"),
reply: getWorkOrderStageTone("reply")
}).toEqual({
dispatch: "neutral",
execution: "info",
reply: "warning"
});
expect(getWorkOrderStageActivity("execution")).toBe("loading");
});
});
@@ -5,51 +5,75 @@ import {
Loader2,
XCircle
} from "lucide-react";
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
import type {
ScheduledConditionItem,
ScheduledConditionStatus,
ScheduledConditionItemStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
export const statusLabels: Record<ScheduledConditionStatus, string> = {
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
warning: "关注",
error: "异常",
pending: "预约"
};
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
completed: "border-green-100 bg-green-50 text-green-700",
running: "border-blue-100 bg-blue-50 text-blue-700",
warning: "border-orange-100 bg-orange-50 text-orange-700",
error: "border-red-100 bg-red-50 text-red-700",
pending: "border-slate-200 bg-slate-100 text-slate-600"
};
const statusTones = {
completed: "success",
running: "info",
awaiting_reply: "warning",
warning: "warning",
error: "danger",
pending: "neutral"
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
const statusActivities = {
completed: "static",
running: "loading",
awaiting_reply: "static",
warning: "static",
error: "static",
pending: "static"
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
awaiting_reply: Clock3,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
};
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
completed: "bg-green-50 text-green-700",
running: "bg-blue-50 text-blue-700",
warning: "bg-orange-50 text-orange-700",
error: "bg-red-50 text-red-700",
pending: "bg-slate-100 text-slate-600"
};
const workOrderStageTones = {
dispatch: "neutral",
execution: "info",
reply: "warning"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
const workOrderStageActivities = {
dispatch: "static",
execution: "loading",
reply: "static"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"running",
"awaiting_reply",
"warning",
"error",
"completed",
"pending"
];
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
export type ConditionTaskFilterOption = {
value: ConditionTaskFilterValue;
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
};
export type ConditionStatusFilterOption = {
value: ScheduledConditionStatus;
value: ScheduledConditionItemStatus;
label: string;
count: number;
};
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
}
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
const statusCounts = new Map<ScheduledConditionStatus, number>();
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
conditions.forEach((condition) => {
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
if (status === "running") {
return "执行中";
}
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
);
}
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
if (stageId === "execution") {
return "border-blue-100 bg-blue-50 text-blue-700";
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageTones[stageId] : "neutral";
}
if (stageId === "reply") {
return "border-green-100 bg-green-50 text-green-700";
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageActivities[stageId] : "static";
}
return "border-indigo-100 bg-indigo-50 text-indigo-700";
}
export function getStatusIcon(status: ScheduledConditionStatus) {
export function getStatusIcon(status: ScheduledConditionItemStatus) {
return statusIcons[status];
}
export function getStatusIconClassName(status: ScheduledConditionStatus) {
return statusIconClassNames[status];
export function getStatusTone(status: ScheduledConditionItemStatus) {
return statusTones[status];
}
export function getStatusActivity(status: ScheduledConditionItemStatus) {
return statusActivities[status];
}
@@ -4,6 +4,7 @@ import {
ChevronRight,
ClipboardList,
History,
X,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
@@ -20,6 +21,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
@@ -27,8 +29,12 @@ import {
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusActivity,
getStatusIcon,
getStatusIconClassName,
getStatusTone,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
@@ -36,10 +42,18 @@ import {
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
"surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
"surface-control rounded-xl border border-slate-200/70";
export type ScheduledConditionFeedPresentation =
| "desktop-dock"
| "desktop-floating"
| "mobile-sheet";
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
@@ -50,6 +64,7 @@ type ScheduledConditionFeedProps = {
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
@@ -60,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
};
export function ScheduledConditionFeed({
presentation = "desktop-dock",
conditions = [],
expanded: controlledExpanded,
focusRequest,
@@ -70,13 +86,21 @@ export function ScheduledConditionFeed({
onFocusRequestHandled,
onSelectedConditionChange,
onExpandAgent,
onClose,
onLoadHistorySession,
onSubmitPrompt
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
string | null
>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
@@ -89,10 +113,16 @@ export function ScheduledConditionFeed({
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilteredConditions = useMemo(
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
() =>
conditions.filter(
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
),
[conditions, taskFilter]
);
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
const statusFilterOptions = useMemo(
() => createStatusFilterOptions(taskFilteredConditions),
[taskFilteredConditions]
);
const taskFilteredConditionCount = taskFilteredConditions.length;
const filteredConditions = useMemo(
() =>
@@ -101,7 +131,10 @@ export function ScheduledConditionFeed({
),
[statusFilter, taskFilteredConditions]
);
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
const { recentConditions, futureWorkOrders } = useMemo(
() => groupScheduledConditions(filteredConditions),
[filteredConditions]
);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
@@ -170,7 +203,9 @@ export function ScheduledConditionFeed({
return;
}
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
const targetCondition = allTimelineConditions.find(
(condition) => condition.id === focusConditionId
);
if (!targetCondition) {
return;
}
@@ -180,11 +215,20 @@ export function ScheduledConditionFeed({
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
}, [
allTimelineConditions,
focusConditionId,
focusRequestId,
onFocusRequestHandled,
updateExpanded,
updateSelectedConditionId
]);
function handleToggleExpanded() {
updateExpanded(!expanded);
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
updateSelectedConditionId(
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
);
}
function handleSelectTimelineCondition(conditionId: string) {
@@ -226,7 +270,8 @@ export function ScheduledConditionFeed({
id: "scheduled-condition-generated-session",
tone: "error",
title: "工况对话发起失败",
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
message:
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
});
});
return;
@@ -240,15 +285,39 @@ export function ScheduledConditionFeed({
aria-label="工况任务"
aria-hidden={!visible}
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
mobileSheet
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
: cn(
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl"
"max-w-[calc(100vw-7rem)]",
desktopFloating
? cn(
"rounded-2xl",
expanded
? "h-[calc(100dvh-8rem)]"
: "max-h-[calc(100dvh-8rem)]"
)
: "h-full rounded-l-2xl rounded-r-none",
expanded && !desktopDock
? "scheduled-feed-panel-expanded flex flex-col"
: "w-[var(--workbench-condition-width)]"
)
)}
>
<header
className={cn(
"flex items-center gap-2.5 px-2.5 py-2",
CONDITION_CONTROL_SURFACE_CLASS_NAME
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
"rounded-lg"
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
<CalendarClock size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
@@ -271,18 +340,39 @@ export function ScheduledConditionFeed({
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button>
{mobileSheet && onClose ? (
<button
type="button"
aria-label="关闭工况任务"
title="关闭工况任务"
onClick={onClose}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
>
<X size={17} aria-hidden="true" />
</button>
) : null}
</header>
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
compactPresentation
? "flex flex-1"
: expanded
? "scheduled-feed-layout-expanded flex-1"
: "scheduled-feed-layout-collapsed"
)}
>
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
"relative min-h-0 min-w-0 overflow-hidden",
compactPresentation
? expanded
? "scheduled-feed-detail-enter h-full w-full"
: "hidden"
: expanded
? "scheduled-feed-detail-enter h-full"
: "pointer-events-none opacity-0"
)}
aria-hidden={!expanded}
>
@@ -295,9 +385,15 @@ export function ScheduledConditionFeed({
/>
) : null}
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
compactPresentation && (expanded ? "hidden" : "h-full w-full")
)}
>
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
@@ -305,7 +401,7 @@ export function ScheduledConditionFeed({
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
filteredConditionCount={filteredConditionCount}
@@ -325,6 +421,7 @@ export function ScheduledConditionFeed({
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
@@ -344,6 +441,7 @@ type ConditionTimelinePanelProps = {
function ConditionTimelinePanel({
expanded,
presentation,
loading,
recentConditions,
futureWorkOrders,
@@ -365,9 +463,14 @@ function ConditionTimelinePanel({
return (
<section
aria-busy={loading}
data-testid="scheduled-condition-timeline"
className={cn(
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
presentation === "mobile-sheet"
? "h-full max-h-none"
: expanded
? "h-full max-h-none"
: "h-[420px] max-h-[420px]",
CONDITION_CONTENT_SURFACE_CLASS_NAME
)}
>
@@ -385,10 +488,15 @@ function ConditionTimelinePanel({
onResetFilters={onResetFilters}
/>
) : null}
{expanded && !loading && futureWorkOrders.length > 0 ? (
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
<section className="mb-2">
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
<ConditionGroupHeader
icon={ClipboardList}
title="预约任务"
count={futureWorkOrders.length}
compact
/>
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
<ConditionRows
conditions={futureWorkOrders}
selectedConditionId={selectedConditionId}
@@ -397,7 +505,11 @@ function ConditionTimelinePanel({
</div>
</section>
) : null}
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
<ConditionGroupHeader
icon={History}
title="最近工况"
count={loading ? 0 : recentConditions.length}
/>
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
@@ -410,7 +522,11 @@ function ConditionTimelinePanel({
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
}
/>
)}
</div>
@@ -446,7 +562,7 @@ function ConditionFilterBar({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700">筛选</span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
@@ -477,7 +593,7 @@ function ConditionFilterBar({
<button
type="button"
onClick={onResetFilters}
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
>
<XCircle size={12} aria-hidden="true" />
重置筛选
@@ -519,10 +635,15 @@ function ConditionFilterDropdown<TValue extends string>({
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
<span
className="block truncate font-semibold text-slate-800"
title={selectedOption?.label}
>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
<span className="block text-xs leading-3 text-slate-400">
{selectedOption?.count ?? 0} 条
</span>
</span>
<ChevronDown
size={13}
@@ -536,7 +657,10 @@ function ConditionFilterDropdown<TValue extends string>({
sideOffset={6}
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
@@ -582,7 +706,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
aria-hidden="true"
/>
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
@@ -605,10 +732,7 @@ function ConditionDetailSkeleton() {
return (
<DetailScroll>
<div
className={cn(
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
"rounded-xl"
)}
className="relative min-h-[420px] overflow-hidden px-4 py-3"
role="status"
aria-label="工况详情加载中"
>
@@ -626,7 +750,10 @@ function ConditionDetailSkeleton() {
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
<div
key={index}
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
>
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
@@ -640,8 +767,18 @@ function ConditionDetailSkeleton() {
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
<section
className={cn(
CONDITION_CONTENT_SURFACE_CLASS_NAME,
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
)}
>
<div
data-testid="scheduled-condition-detail-scroll"
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
>
{children}
</div>
</section>
);
}
@@ -653,9 +790,19 @@ type ConditionGroupHeaderProps = {
compact?: boolean;
};
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
function ConditionGroupHeader({
icon: Icon,
title,
count,
compact = false
}: ConditionGroupHeaderProps) {
return (
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
<div
className={cn(
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
compact ? "mb-1" : "mb-2"
)}
>
<span className="flex items-center gap-1.5">
<Icon size={13} aria-hidden="true" />
<span>{title}</span>
@@ -694,9 +841,21 @@ type ConditionTimelineRowProps = {
onSelect: () => void;
};
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
const StatusIcon = getStatusIcon(condition.status);
function ConditionTimelineRow({
condition,
selected,
isLast,
onSelect
}: ConditionTimelineRowProps) {
const Icon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
const tone = isWorkOrder
? getWorkOrderStageTone(workOrderStage)
: getStatusTone(condition.status);
const activity = isWorkOrder
? getWorkOrderStageActivity(workOrderStage)
: getStatusActivity(condition.status);
return (
<button
@@ -705,44 +864,48 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
selected
? "surface-reading border-blue-100 text-blue-900"
: "border-transparent hover:bg-slate-50"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
<span
className="relative flex min-h-12 justify-center pt-1.5"
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
{formatTime(condition.scheduledAt)}
</span>
<span className="relative flex min-h-12 justify-center pt-1.5">
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
aria-hidden="true"
/>
) : null}
<span
<SemanticStatusIcon
tone={tone}
activity={activity}
shape="circle"
data-selected={selected ? "true" : undefined}
className={cn(
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
selected
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
: isWorkOrder
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
: getStatusIconClassName(condition.status)
"relative z-10 ring-4 ring-white",
selected && "group-focus-visible:ring-blue-100"
)}
>
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
</span>
<Icon size={12} />
</SemanticStatusIcon>
</span>
<span
className={cn(
"min-w-0 px-2 py-1.5 transition-colors",
selected
? "text-blue-900"
: "text-slate-700"
selected ? "text-blue-900" : "text-slate-700"
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
<span className="block truncate text-xs font-semibold leading-4">
{condition.title}
</span>
<span className="block truncate text-xs leading-4 text-slate-500">
{condition.summary}
</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
</span>
@@ -1,12 +1,6 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { ComponentProps } from "react";
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
import { useEffect, type ComponentProps } from "react";
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -14,105 +8,52 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
mobileOpen: boolean;
mobilePanelCollapsing: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
onCollapsePanel: () => void;
onCloseMobilePanel: () => void;
onExpandPanel: () => void;
onOpenMobilePanel: () => void;
onWidthCommit?: (width: number) => void;
};
export function WorkbenchAgentPanels({
panelProps,
panelOpen,
panelCollapsing,
mobileOpen,
mobilePanelCollapsing,
personaState,
statusLabel,
viewportWidth,
onCollapsePanel,
onCloseMobilePanel,
onExpandPanel,
onOpenMobilePanel
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth);
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
useEffect(() => {
onWidthCommit?.(committedWidth);
}, [committedWidth, onWidthCommit]);
return (
<>
<div
ref={resize.panelRef}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
style={resize.width === null ? undefined : { width: resize.width }}
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
style={{ width: panelOpen ? committedWidth : 0 }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={resize.handleKeyDown}
onPointerDown={resize.handlePointerDown}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
) : (
<div className="absolute left-3 top-5">
<AgentCollapsedRail
personaState={personaState}
statusLabel={statusLabel}
onExpand={onExpandPanel}
/>
</div>
)}
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...panelProps}
collapsing={mobilePanelCollapsing}
onCollapse={onCloseMobilePanel}
/>
</div>
) : null}
</div>
{!mobileOpen ? (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpenMobilePanel}
className={cn(
"grid h-12 w-12 place-items-center text-violet-700",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
</button>
</div>
) : null}
</>
);
}
@@ -24,6 +24,12 @@ import {
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +42,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
compactHeaderTextClassName,
headerControlButtonClassName,
headerControlIconClassName
@@ -47,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
review: "待复核"
};
const scenarioStatusTones = {
active: "info",
draft: "neutral",
review: "warning"
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
@@ -112,9 +125,9 @@ export function ScenarioMenu({
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
{scenarioStatusLabels[scenario.status]}
</span>
</StatusBadge>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
@@ -161,29 +174,38 @@ export function AlertMenu({
className={cn(
"group relative px-2",
headerControlButtonClassName,
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
compact && "h-10 w-10 justify-center px-0"
hasAlerts && !compact &&
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
>
<span
className={cn(
headerControlIconClassName,
hasAlerts &&
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
)}
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
{compact ? (
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
className={cn(
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
!hasAlerts && "border-transparent! bg-transparent!"
)}
>
{alerts.length}
</span>
</StatusBadge>
) : (
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
>
{alerts.length}
</span>
</StatusBadge>
)}
</button>
</DropdownMenuTrigger>
@@ -199,7 +221,7 @@ export function AlertMenu({
<DropdownMenuItem
key={alert.id}
className={cn(
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
@@ -207,20 +229,22 @@ export function AlertMenu({
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
<StatusDot tone="danger" size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} />
</SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
</div>
@@ -259,7 +283,8 @@ export function UserMenu({
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -268,13 +293,18 @@ export function UserMenu({
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group px-2", headerControlButtonClassName)}
className={cn(
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
headerControlButtonClassName,
compactHeaderControlButtonClassName
)}
aria-label="打开用户菜单"
>
<span className={headerControlIconClassName}>
@@ -292,7 +322,15 @@ export function UserMenu({
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<DropdownMenuItem
disabled={!onLogout}
onSelect={onLogout ? () => void onLogout() : undefined}
className={cn(
"px-2 py-2",
!onLogout && "text-slate-400",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
<LogOut size={15} aria-hidden="true" />
退出登录
</DropdownMenuItem>
@@ -316,15 +354,13 @@ function AlertQueueSummary({
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
)}
<SemanticStatusIcon
tone={hasAlerts ? "danger" : "success"}
size="md"
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
</span>
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
</SemanticStatusIcon>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-500">
@@ -332,17 +368,12 @@ function AlertQueueSummary({
</p>
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
)}
>
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
{hasAlerts ? "需复核" : "正常"}
</span>
</StatusBadge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
</div>
</div>
@@ -427,9 +458,9 @@ function MenuHeader({
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
</div>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
</div>
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
</div>
);
}
@@ -469,7 +500,7 @@ function MenuAction({
"grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
)}
>
@@ -1,7 +1,16 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName =
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
cn(
buttonInteractionVariants({ tone: "neutral" }),
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
);
export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0";
export const headerControlIconClassName =
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
export const compactHeaderTextClassName = "hidden 2xl:inline";
@@ -7,11 +7,10 @@ import {
Eye,
EyeOff,
FlaskConical,
Sparkles,
type LucideIcon
} from "lucide-react";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
@@ -29,6 +28,7 @@ export type WorkbenchTopBarProps = {
alerts: WorkbenchAlert[];
user: WorkbenchUser;
conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
devPanelEnabled: boolean;
@@ -36,16 +36,19 @@ export type WorkbenchTopBarProps = {
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
onToggleDevPanel: () => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
@@ -58,6 +61,7 @@ export function WorkbenchTopBar({
alerts,
user,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
taskTickerVisible,
devPanelEnabled,
@@ -65,19 +69,23 @@ export function WorkbenchTopBar({
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
onToggleDevPanel,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport,
onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const activeScenario =
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null);
};
@@ -85,11 +93,18 @@ export function WorkbenchTopBar({
return (
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<div
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)}
>
<Droplets size={18} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
供水管网智能调度系统
</h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
</div>
</div>
@@ -132,19 +147,47 @@ export function WorkbenchTopBar({
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{devPanelEnabled ? <div className="hidden lg:block">
<div className="flex shrink-0 items-center gap-2">
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
<div className="hidden lg:block">
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
{onCreateTestAnalysis ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="生成 Agent 模拟分析成果"
title="生成 Agent 模拟分析成果"
data-selection-tone="soft"
onClick={onCreateTestAnalysis}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Sparkles size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>测试分析</span>
</button>
</div>
) : null}
{devPanelEnabled ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="切换地图 Dev Panel"
aria-pressed={devPanelOpen}
data-selection-tone="soft"
onClick={onToggleDevPanel}
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
<span className={headerControlIconClassName}>
<FlaskConical size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>Dev</span>
</button>
</div> : null}
</div>
) : null}
<div className="lg:hidden">
<AlertMenu
compact
@@ -165,6 +208,7 @@ export function WorkbenchTopBar({
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
onLogout={onLogout}
/>
</div>
</header>
@@ -191,20 +235,12 @@ function TaskTickerToggle({
type="button"
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
aria-pressed={visible}
data-selection-tone="soft"
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Icon size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>任务浮条</span>
@@ -212,30 +248,18 @@ function TaskTickerToggle({
);
}
function ConditionFeedToggle({
visible,
onToggle
}: {
visible: boolean;
onToggle: () => void;
}) {
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
return (
<button
type="button"
aria-label="工况任务"
aria-pressed={visible}
data-selection-tone="soft"
title="工况任务"
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<CalendarClock size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>工况任务</span>
@@ -243,12 +267,49 @@ function ConditionFeedToggle({
);
}
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
function TimelineToggle({
visible,
compact = false,
onToggle
}: {
visible: boolean;
compact?: boolean;
onToggle: () => void;
}) {
return (
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<button
type="button"
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
aria-pressed={visible}
data-selection-tone="soft"
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
onClick={onToggle}
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Clock3 size={14} aria-hidden="true" />
</span>
{!compact ? <span className={compactHeaderTextClassName}>时间轴</span> : null}
</button>
);
}
function HeaderReadout({
icon: Icon,
label,
value
}: {
icon?: LucideIcon;
label: string;
value: string;
}) {
return (
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
{value}
</span>
</div>
);
}
@@ -28,4 +28,37 @@ describe("createOperationalScheduledConditions", () => {
expect(currentCondition?.status).toBe("running");
expect(currentCondition?.durationMinutes).toBe(5);
});
it("labels the active work-order stage as executing", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const executingWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
)
);
expect(
executingWorkOrder?.kind === "work_order"
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
: undefined
).toBe("执行中");
});
it("moves completed field work into a waiting-for-reply state", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const awaitingReplyWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
);
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
expect(
awaitingReplyWorkOrder?.kind === "work_order"
? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title
: undefined
).toBe("待复令");
});
});
@@ -5,7 +5,8 @@ import type {
ScheduledConditionReport,
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId
ScheduledConditionTaskId,
ScheduledWorkOrderStatus
} from "../types";
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
@@ -16,6 +17,7 @@ const MODEL_NAME = "DrainFlow Realtime Simulator";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId;
type WorkOrderPhase = "dispatch" | "execution" | "reply";
type ConditionTaskDefinition = {
id: ConditionTaskId;
@@ -332,6 +334,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
return {
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
@@ -340,7 +343,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
title: workOrder.title,
summary: workOrder.summary,
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
status: getWorkOrderStatus(phase),
riskLevel: workOrder.riskLevel,
updatedAt: now.getTime(),
detail: workOrder.detail,
@@ -352,7 +355,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
assignee: workOrder.assignee,
priority: workOrder.priority,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
replyRequirements:
currentMinutesOfDay > replyDeadlineMinute
? []
@@ -385,11 +388,38 @@ function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMin
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
}
function getWorkOrderPhase(
workOrder: ManualWorkOrderDefinition,
currentMinutesOfDay: number
): WorkOrderPhase {
if (currentMinutesOfDay < workOrder.startMinute) {
return "dispatch";
}
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
return "execution";
}
return "reply";
}
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
if (phase === "dispatch") {
return "pending";
}
if (phase === "execution") {
return "running";
}
return "awaiting_reply";
}
function createWorkOrderStages(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
currentMinutesOfDay: number
phase: WorkOrderPhase
) {
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
@@ -397,37 +427,32 @@ function createWorkOrderStages(
now,
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
);
const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes;
return [
{
id: "dispatch" as const,
title: "派发",
status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const),
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution" as const,
title: "执行",
title: "执行中",
status:
currentMinutesOfDay >= executionEndMinute
? ("done" as const)
: currentMinutesOfDay >= workOrder.startMinute
phase === "dispatch"
? ("pending" as const)
: phase === "execution"
? ("current" as const)
: ("pending" as const),
: ("done" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
description: workOrder.summary
},
{
id: "reply" as const,
title: "复令",
status:
currentMinutesOfDay >= executionEndMinute
? ("current" as const)
: ("pending" as const),
title: "待复令",
status: phase === "reply" ? ("current" as const) : ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
@@ -9,27 +9,44 @@ import {
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchViewportLayout
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number) {
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const widthRef = useRef<number | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
setWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
if (widthRef.current === null) {
return;
}
const resize = useCallback((clientX: number) => {
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
}, []);
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
widthRef.current = nextWidth;
setWidth(nextWidth);
setCommittedWidth(nextWidth);
}, [conditionExpanded, viewportWidth]);
const resize = useCallback(
(clientX: number) => {
const nextWidth = clampAgentPanelWidth(
clientX - resizeLeftRef.current,
window.innerWidth,
conditionExpanded
);
widthRef.current = nextWidth;
setWidth(nextWidth);
},
[conditionExpanded]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -48,6 +65,7 @@ export function useAgentPanelResize(viewportWidth: number) {
const stopResizing = () => {
cleanupRef.current?.();
setResizing(false);
setCommittedWidth(widthRef.current);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
@@ -64,7 +82,8 @@ export function useAgentPanelResize(viewportWidth: number) {
[resize]
);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
@@ -74,18 +93,24 @@ export function useAgentPanelResize(viewportWidth: number) {
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
} else if (event.key === "End") {
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
} else {
return;
}
event.preventDefault();
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
widthRef.current = clampedWidth;
setWidth(clampedWidth);
setCommittedWidth(clampedWidth);
},
[conditionExpanded]
);
return {
committedWidth,
handleKeyDown,
handlePointerDown,
panelRef,
@@ -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);
@@ -7,15 +7,31 @@ export function useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
rightPanelOpen,
conditionPanelExpanded = false,
agentPanelWidth
}: {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
conditionPanelExpanded?: boolean;
agentPanelWidth?: number;
}) {
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 +40,24 @@ 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 };
}
});
}
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 };
}
@@ -16,11 +16,7 @@ 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,
@@ -47,8 +43,6 @@ declare global {
type UseWorkbenchMapOptions = {
containerRef: RefObject<HTMLDivElement | null>;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
@@ -81,30 +75,25 @@ 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(
AVAILABLE_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
}: 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>>({});
useEffect(() => {
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
}, [leftPanelOpen, rightPanelOpen]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
}, [impactVisible]);
@@ -130,38 +119,31 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
const sources = createWaterNetworkSources();
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]));
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
map.addSource(sourceId, sources[sourceId])
);
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers = scadaRegistration.status === "unavailable"
const activeScadaBusinessLayers =
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
: scadaBusinessLayers;
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[0],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["==", ["geometry-type"], "Point"],
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[1],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type === "symbol")
.forEach((layer) => map.addLayer(layer));
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
setSimulationLayersVisibility(map, impactVisibleRef.current);
@@ -169,7 +151,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 +166,19 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
});
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 +193,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,6 +209,10 @@ export function useWorkbenchMap({
return () => {
window.removeEventListener("resize", resizeMap);
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -248,7 +251,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 +266,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 +321,21 @@ 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"
? "业务标注和影响范围已加载。"
: "业务标注源加载中断,模拟标注可能暂不可见。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
@@ -0,0 +1,85 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchResponsiveLayout } from "./use-workbench-responsive-layout";
function mockViewport(width: number, largeScreen: boolean) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width
});
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: largeScreen,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workbench responsive layout", () => {
it("coordinates the expanded condition feed with the desktop Agent panel", () => {
mockViewport(1366, true);
const collapseAgentPanel = vi.fn();
const expandAgentPanel = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: false,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool: vi.fn()
})
);
expect(result.current.isLargeScreen).toBe(true);
expect(result.current.shouldShowConditionFeed).toBe(false);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.shouldShowConditionFeed).toBe(true);
act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true);
expect(collapseAgentPanel).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(false));
expect(result.current.conditionFeedExpanded).toBe(false);
expect(expandAgentPanel).toHaveBeenCalledOnce();
});
it("toggles the condition sheet without leaking desktop panel state on mobile", () => {
mockViewport(390, false);
const onClearActiveTool = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: true,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel: vi.fn(),
expandAgentPanel: vi.fn(),
onClearActiveTool
})
);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBe("condition");
expect(result.current.mobileSheetSnap).toBe("half");
expect(onClearActiveTool).toHaveBeenCalledOnce();
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBeNull();
act(() => result.current.openAgentPanelForViewport());
expect(result.current.mobileSheet).toBe("agent");
});
});
@@ -0,0 +1,181 @@
import { useEffect, useState } from "react";
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
type UseWorkbenchResponsiveLayoutOptions = {
activeToolOpen: boolean;
agentPanelOpen: boolean;
devPanelOpen: boolean;
collapseAgentPanel: () => void;
expandAgentPanel: () => void;
onClearActiveTool: () => void;
};
export function useWorkbenchResponsiveLayout({
activeToolOpen,
agentPanelOpen,
devPanelOpen,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool
}: UseWorkbenchResponsiveLayoutOptions) {
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(LARGE_SCREEN_QUERY);
const handleViewportChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleViewportChange();
setConditionFeedVisible(false);
mediaQuery.addEventListener("change", handleViewportChange);
window.addEventListener("resize", handleViewportChange);
return () => {
mediaQuery.removeEventListener("change", handleViewportChange);
window.removeEventListener("resize", handleViewportChange);
};
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, CONDITION_FEED_EXIT_MS);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
function closeMobileSheet() {
setMobileSheet(null);
}
function openAgentPanelForViewport() {
if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false);
}
expandAgentPanel();
return;
}
setMobileSheet("agent");
setMobileSheetSnap("half");
}
function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setAgentCollapsedForCondition(agentPanelOpen);
collapseAgentPanel();
} else if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
setConditionFeedExpanded(nextExpanded);
}
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
return;
}
setConditionFeedExpanded(false);
if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
}, [
agentCollapsedForCondition,
conditionFeedExpanded,
expandAgentPanel,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
onClearActiveTool();
if (isLargeScreen) {
setConditionFeedVisible((current) => !current);
return;
}
if (mobileSheet === "condition") {
closeMobileSheet();
return;
}
setConditionFeedExpanded(false);
setMobileSheetSnap("half");
setMobileSheet("condition");
}
function openConditionFeedForViewport() {
setConditionFeedVisible(true);
onClearActiveTool();
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
}
function openTimelineForViewport() {
if (isLargeScreen) return;
setMobileSheet("timeline");
setMobileSheetSnap("half");
}
const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
return {
agentPanelWidth,
closeMobileSheet,
conditionFeedExpanded,
conditionFeedMounted,
handleConditionExpandedChange,
isLargeScreen,
leftPanelOpen,
mobileSheet,
mobileSheetSnap,
openAgentPanelForViewport,
openConditionFeedForViewport,
openTimelineForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
setMobileSheetSnap,
shouldShowConditionFeed,
toggleConditionFeedForViewport,
viewportWidth
};
}
+24
View File
@@ -6,9 +6,33 @@ export type {
WorkbenchMapControllerState,
WorkbenchMapErrorCode
} from "./map/workbench-map-controller";
export type {
PipeFlowCalculation,
PipeFlowDirection,
PipeFlowSummary
} from "./map/flow-overlay";
export type {
LayerGroupId,
LayerGroupStylePatch,
LineLayerGroupStylePatch,
PointLayerGroupStylePatch
} from "./map/layer-group-style";
export type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
ArtifactLifecycle,
ArtifactMapRelation,
ArtifactRequestedView,
ArtifactType,
ArtifactViewMode,
WorkbenchSurfaceMode
} from "./workspace/workspace-model";
export type {
OperationalEvent,
OperationalEventImportance,
OperationalEventLane,
OperationalEventStatus,
OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
export type { WorkspaceContext, WorkspaceTimeRange } from "./workspace/workspace-context";
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { getWorkbenchBasemapTone } from "./workbench-layout";
import {
clampAgentPanelWidth,
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth,
getWorkbenchBasemapTone,
getWorkbenchCameraPadding,
getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout";
describe("workbench basemap surface tone", () => {
it.each([
@@ -11,3 +19,90 @@ describe("workbench basemap surface tone", () => {
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
});
});
describe("workbench floating panels", () => {
it("derives mobile dock and map control offsets from one overlay stack", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
});
});
it("uses bounded desktop and wide panel widths", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500,
conditionWidth: 432,
conditionExpandedWidth: 880,
toolbarWidth: 48
});
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
agentWidth: 540,
conditionExpandedWidth: 960
});
});
it("tracks the expanded condition panel in camera padding", () => {
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: true,
conditionOpen: true,
conditionExpanded: false
})
).toEqual({ top: 72, right: 504, bottom: 32, left: 524 });
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: true,
conditionOpen: true,
conditionExpanded: true
})
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
});
it("does not reserve map width for the collapsed Agent launcher", () => {
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: false,
conditionOpen: false
}).left
).toBe(24);
});
it("uses the committed Agent width without exceeding the hard limit", () => {
expect(
getWorkbenchCameraPadding(1920, {
agentOpen: true,
agentWidth: 620,
conditionOpen: true
})
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
expect(
getWorkbenchCameraPadding(1920, {
agentOpen: true,
agentWidth: 900,
conditionOpen: false
}).left
).toBe(744);
});
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
expect(getAgentPanelDefaultWidth(390)).toBe(500);
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
expect(getAgentPanelMaxWidth(1024)).toBe(484);
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
expect(getAgentPanelMaxWidth(1280)).toBe(516);
expect(getAgentPanelMaxWidth(1440)).toBe(676);
expect(getAgentPanelMaxWidth(1536)).toBe(720);
expect(getAgentPanelMaxWidth(1920)).toBe(720);
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
});
});
@@ -3,33 +3,101 @@ export const WORKBENCH_LAYOUT = {
desktopMinWidth: 1024,
persistentConditionMinWidth: 1280,
wideMinWidth: 1536,
collapsedAgentWidth: 72,
maxAgentViewportRatio: 0.5,
collapsedAgentWidth: 0,
maxAgentWidth: 720,
agentPanelLeftInset: 12,
conditionPanelRightInset: 64,
minimumAgentMapCorridor: 256,
mapEdgeGap: 24,
desktop: {
agentWidth: 460,
agentWidth: 500,
conditionWidth: 432,
conditionExpandedWidth: 880,
toolbarWidth: 48,
tickerWidth: 420
},
wide: {
agentWidth: 500,
agentWidth: 540,
conditionWidth: 432,
conditionExpandedWidth: 960,
toolbarWidth: 48,
tickerWidth: 460
},
mobileSheet: {
halfViewportRatio: 0.52,
fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
}
} as const;
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth(
width: number,
viewportWidth: number,
conditionExpanded = false
) {
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
return Math.round(
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
);
}
export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpanded = false) {
const layout = getWorkbenchViewportLayout(viewportWidth);
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
return layout.agentWidth;
}
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
const collisionSafeWidth =
viewportWidth -
WORKBENCH_LAYOUT.agentPanelLeftInset -
WORKBENCH_LAYOUT.mapEdgeGap -
conditionWidth -
WORKBENCH_LAYOUT.mapEdgeGap -
layout.toolbarWidth;
return Math.min(layout.agentWidth, collisionSafeWidth);
}
export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded = false) {
const layout = getWorkbenchViewportLayout(viewportWidth);
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const widthWithMapCorridor =
viewportWidth -
WORKBENCH_LAYOUT.agentPanelLeftInset -
WORKBENCH_LAYOUT.conditionPanelRightInset -
conditionWidth -
WORKBENCH_LAYOUT.minimumAgentMapCorridor;
return Math.max(defaultWidth, Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithMapCorridor));
}
export type WorkbenchPanelState = {
agentOpen: boolean;
agentWidth?: number;
conditionOpen: boolean;
conditionExpanded?: boolean;
};
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
@@ -37,6 +105,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
export type WorkbenchViewportLayout = {
agentWidth: number;
conditionWidth: number;
conditionExpandedWidth: number;
toolbarWidth: number;
tickerWidth: number;
};
@@ -65,8 +134,23 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
}
const layout = getWorkbenchViewportLayout(viewportWidth);
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
const agentWidth = panels.agentOpen
? panels.agentWidth === undefined
? getAgentPanelDefaultWidth(
viewportWidth,
panels.conditionOpen && Boolean(panels.conditionExpanded)
)
: clampAgentPanelWidth(
panels.agentWidth,
viewportWidth,
panels.conditionOpen && Boolean(panels.conditionExpanded)
)
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const conditionWidth = panels.conditionOpen
? panels.conditionExpanded
? layout.conditionExpandedWidth
: layout.conditionWidth
: 0;
return {
top: WORKBENCH_LAYOUT.headerHeight + 16,
@@ -82,6 +166,8 @@ export function getWorkbenchLayoutCssVariables() {
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
"--workbench-condition-expanded-width-wide": `${WORKBENCH_LAYOUT.wide.conditionExpandedWidth}px`,
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
+437 -114
View File
@@ -2,18 +2,22 @@ import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ComponentProps,
type CSSProperties
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarClock, Clock3 } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult
type AgentUiResult,
type AgentWorkspaceContextItem
} from "@/features/agent";
import {
MapErrorNotice,
@@ -27,11 +31,22 @@ import {
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
createOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout,
type OperationalEvent,
type OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
import {
ToolbarPanel,
type ExportViewPreset,
@@ -39,11 +54,20 @@ import {
type ToolbarToolId
} from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import {
createInitialWorkspaceContext,
reduceWorkspaceContext
} from "./workspace/workspace-context";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
@@ -54,7 +78,8 @@ import {
import {
WORKBENCH_LAYOUT,
getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables
getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
@@ -63,7 +88,6 @@ import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility,
createLayerControlItems,
getWorkbenchLayerIds
@@ -73,7 +97,8 @@ import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
WorkbenchAlert
WorkbenchAlert,
WorkbenchUser
} from "./types";
import {
createScheduledConditionAlerts,
@@ -83,19 +108,23 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
export function MapWorkbenchPage() {
export function MapWorkbenchPage({
user = WORKBENCH_USER,
onLogout,
getAccessToken
}: {
user?: WorkbenchUser;
onLogout?: () => Promise<void>;
getAccessToken?: AccessTokenProvider;
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
@@ -128,59 +157,110 @@ 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;
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();
}
agent.openMobilePanel();
function openConditionWorkspaceForViewport() {
openConditionFeedForViewport();
}
const leftPanelOpen = isLargeScreen && agent.panelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
function toggleConditionWorkspaceForViewport() {
toggleConditionFeedForViewport();
}
function toggleTimelineVisibility() {
setTimelineVisible((current) => {
if (current && mobileSheet === "timeline") closeMobileSheet();
return !current;
});
}
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
@@ -188,8 +268,10 @@ export function MapWorkbenchPage() {
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
leftPanelOpen: false,
rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth
});
const activeAgentUiResults = useMemo(
@@ -223,10 +305,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 +364,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 +440,7 @@ export function MapWorkbenchPage() {
}
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
});
}
@@ -404,11 +535,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 +580,7 @@ export function MapWorkbenchPage() {
return;
}
openAgentPanelForViewport();
openAgentWorkspaceForViewport();
try {
await agent.submitPrompt(
@@ -487,8 +617,7 @@ export function MapWorkbenchPage() {
return;
}
setConditionFeedVisible(true);
setActiveToolId(null);
openConditionWorkspaceForViewport();
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
@@ -608,7 +737,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 +857,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 +896,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 +904,7 @@ export function MapWorkbenchPage() {
selectedModel: agent.selectedModel,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -757,91 +923,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 +1092,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 +1104,11 @@ export function MapWorkbenchPage() {
</div>
) : null}
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
</div>
<AnimatePresence initial={false}>
{shouldShowTaskTicker ? (
<motion.div
key="agent-task-ticker-shell"
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
style={{ transformOrigin: "top center" }}
initial={
prefersReducedMotion
@@ -890,15 +1127,93 @@ export function MapWorkbenchPage() {
) : null}
</AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
{!agent.mobileOpen ? (
<div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? (
<div className="flex items-center justify-center gap-2">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={openAgentPanelForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
>
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
</button>
{artifactWorkspace.surfaceMode !== "focus" ? (
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="w-full justify-center"
className="h-11 justify-center"
/>
) : null}
<button
type="button"
aria-label="打开工况任务"
title="打开工况任务"
onClick={toggleConditionFeedForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
>
<CalendarClock size={20} aria-hidden="true" />
</button>
</div>
) : null}
</div>
{mobileSheet ? (
<MobileWorkbenchSheet
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
: mobileSheet === "timeline"
? "运行时间轴抽屉"
: "工况任务抽屉"
}
snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet}
>
{mobileSheet === "agent" ? (
<AgentCommandPanel
{...agentPanelProps}
presentation="mobile-sheet"
onCollapse={closeMobileSheet}
/>
) : mobileSheet === "timeline" ? (
<OperationalTimeline
mobile
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
) : (
<ScheduledConditionFeed
presentation="mobile-sheet"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
)}
</MobileWorkbenchSheet>
) : null}
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
@@ -951,3 +1266,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
return "当前分辨率";
}
function formatContextClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
@@ -72,7 +72,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold"],
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
@@ -89,7 +89,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Open Sans Bold"],
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
@@ -107,7 +107,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Regular"],
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
@@ -124,7 +124,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Open Sans Regular"],
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
+35 -11
View File
@@ -1,10 +1,6 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
fitNetworkBounds,
getResponsiveWorkbenchPadding,
getWorkbenchPadding
} from "./camera";
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) {
@@ -19,12 +15,40 @@ function createMap(width = 1280, height = 720) {
describe("workbench camera padding", () => {
it.each([
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
{
agentOpen: false,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 24 }
},
{
agentOpen: true,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 524 }
},
{
agentOpen: false,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 24 }
},
{
agentOpen: true,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 524 }
}
])(
"covers agent=$agentOpen and condition=$conditionOpen",
({ agentOpen, conditionOpen, expected }) => {
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
}
);
it("uses a committed Agent width for camera-safe operations", () => {
expect(getWorkbenchPadding(true, false, false, 620)).toEqual({
top: 72,
right: 72,
bottom: 32,
left: 540
});
});
it("caps combined padding on constrained desktop widths", () => {
@@ -59,7 +83,7 @@ describe("fitNetworkBounds", () => {
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
expect(bounds[0][1]).toBeCloseTo(30.8105, 6);
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({
+19 -16
View File
@@ -1,7 +1,5 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import {
getWorkbenchCameraPadding
} from "../layout/workbench-layout";
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
type LngLatBounds = [[number, number], [number, number]];
@@ -14,24 +12,35 @@ export type NetworkViewParams = {
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
export function getWorkbenchPadding(
leftPanelOpen: boolean,
rightPanelOpen: boolean,
conditionPanelExpanded = false,
agentPanelWidth?: number
): PaddingOptions {
return getWorkbenchCameraPadding(1280, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
agentWidth: agentPanelWidth,
conditionOpen: rightPanelOpen,
conditionExpanded: conditionPanelExpanded
});
}
export function getResponsiveWorkbenchPadding(
map: MapLibreMap,
leftPanelOpen: boolean,
rightPanelOpen: boolean
rightPanelOpen: boolean,
conditionPanelExpanded = false,
agentPanelWidth?: number
): PaddingOptions {
const width = getMapViewportSize(map).width;
return getResponsivePadding(
map,
getWorkbenchCameraPadding(width, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
agentWidth: agentPanelWidth,
conditionOpen: rightPanelOpen,
conditionExpanded: conditionPanelExpanded
})
);
}
@@ -41,14 +50,11 @@ export function fitNetworkBounds(
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = GLOBAL_VIEW_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
duration: 600
}
);
});
}
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
@@ -84,10 +90,7 @@ function getMapViewportSize(map: MapLibreMap) {
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
const [minX, minY, maxX, maxY] = bbox3857;
return [
webMercatorToLngLat(minX, minY),
webMercatorToLngLat(maxX, maxY)
];
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
@@ -9,7 +9,7 @@ import {
} from "./map-colors";
import { createBaseStyle } from "./sources";
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
const mapBackgrounds = [
MAP_STYLE_TOKENS.canvas.primary,
MAP_STYLE_TOKENS.canvas.secondary,
@@ -47,7 +47,6 @@ describe("map color contrast", () => {
});
it("keeps selected outlines above 4.5:1 on every map background", () => {
sourceColors.forEach(() => {
mapBackgrounds.forEach((background) => {
const selectedColor = MAP_STYLE_TOKENS.state.selected;
expect(
@@ -56,9 +55,27 @@ describe("map color contrast", () => {
).toBeGreaterThanOrEqual(4.5);
});
});
it("keeps operational states above 3:1 on every map background", () => {
const operationalColors = [
MAP_STYLE_TOKENS.state.success,
MAP_STYLE_TOKENS.state.risk,
MAP_STYLE_TOKENS.state.incident,
MAP_STYLE_TOKENS.state.inactive,
MAP_STYLE_TOKENS.state.agent
];
operationalColors.forEach((stateColor) => {
mapBackgrounds.forEach((background) => {
expect(
getContrastRatio(stateColor, background),
createCoolorsContrastUrl(stateColor, background)
).toBeGreaterThanOrEqual(3);
});
});
});
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
MAP_STYLE_TOKENS.state.inactive,
+169 -13
View File
@@ -1,21 +1,177 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
import {
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID,
clearPipeFlowCalculations,
createFlowOverlayState,
getPipeFlowDirection,
replacePipeFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation
} from "./flow-overlay";
describe("flow overlay lifecycle", () => {
it("starts empty so conduit data can be loaded once per map session", () => {
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
describe("flow overlay", () => {
it("classifies signed velocities relative to pipe geometry", () => {
expect(getPipeFlowDirection(2.5)).toBe(1);
expect(getPipeFlowDirection(-0.01)).toBe(-1);
expect(getPipeFlowDirection(0)).toBe(0);
expect(getPipeFlowDirection(Number.NaN)).toBe(0);
});
it("cancels an active animation frame without clearing cached data", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("window", { cancelAnimationFrame });
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
const { map, layers } = createMap();
const state = createFlowOverlayState();
state.data = [];
state.animationFrameId = 42;
stopFlowAnimation(state);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(state.animationFrameId).toBeNull();
expect(state.data).toBeTruthy();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
});
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
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: "geo_pipes_mat",
id: "P-1"
}, {
flowDirection: -1
});
expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), {
flowDirection: 1
});
expect(first).toMatchObject({ total: 1, reverse: 1 });
expect(second).toMatchObject({ total: 1, forward: 1 });
expect(map.getSource).not.toHaveBeenCalled();
});
it("replaces a snapshot and clears only stale flow state keys", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-old",
velocity: 1
});
map.removeFeatureState.mockClear();
const summary = await replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
[
{ pipeId: "P-new", velocity: -3 },
{ pipeId: "P-new", velocity: 4 },
{ pipeId: "", velocity: 1 }
]
);
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({
id: "P-old"
}), "flowDirection");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(summary).toEqual({
total: 1,
forward: 1,
reverse: 0,
stopped: 0,
invalid: 1
});
});
it("returns to the all-pipe mock after clearing explicit calculations", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: 1
});
await clearPipeFlowCalculations(map as unknown as MapLibreMap, state);
const latestOpacity = map.setPaintProperty.mock.calls
.filter(([, property]) => property === "line-opacity")
.at(-1)?.[2];
expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]');
});
it("applies large snapshots in animation-frame batches", async () => {
const callbacks: FrameRequestCallback[] = [];
vi.stubGlobal("window", {
matchMedia: () => ({ matches: false }),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callbacks.push(callback);
return callbacks.length;
}),
cancelAnimationFrame: vi.fn()
});
const { map } = createMap();
const state = createFlowOverlayState();
const records = Array.from({ length: 2_001 }, (_, index) => ({
pipeId: index,
velocity: index % 2 === 0 ? 1 : -1
}));
const pending = replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
records
);
expect(map.setFeatureState).toHaveBeenCalledTimes(2_000);
callbacks.shift()?.(16);
const summary = await pending;
expect(map.setFeatureState).toHaveBeenCalledTimes(2_001);
expect(summary.total).toBe(2_001);
vi.unstubAllGlobals();
});
});
function createMap() {
const layers = new Map<string, unknown>();
const images = new Set<string>();
const map = {
addImage: vi.fn((id: string) => {
images.add(id);
}),
addLayer: vi.fn((layer: { id: string }) => {
layers.set(layer.id, layer);
return map;
}),
getLayer: vi.fn((id: string) => layers.get(id)),
getSource: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
removeFeatureState: vi.fn(),
setFeatureState: vi.fn(),
setLayoutProperty: vi.fn(),
setPaintProperty: vi.fn((id: string, property: string, value: unknown) => {
const layer = layers.get(id) as { paint?: Record<string, unknown> } | undefined;
if (layer?.paint) layer.paint[property] = value;
}),
triggerRepaint: vi.fn()
};
return { map, layers };
}
+405 -67
View File
@@ -1,99 +1,437 @@
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { MapboxOverlay } from "@deck.gl/mapbox";
import type {
ExpressionSpecification,
Map as MapLibreMap,
StyleImageInterface
} from "maplibre-gl";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_LAYERS } from "./sources";
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward";
export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse";
const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward";
const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse";
const FLOW_STATE_DIRECTION = "flowDirection";
const FLOW_PATTERN_WIDTH = 64;
const FLOW_PATTERN_HEIGHT = 8;
const FLOW_PATTERN_DURATION_MS = 1_200;
export type PipeFlowCalculation = {
pipeId: string | number;
velocity: number;
};
export type PipeFlowDirection = -1 | 0 | 1;
export type PipeFlowSummary = {
total: number;
forward: number;
reverse: number;
stopped: number;
invalid: number;
};
type NormalizedPipeFlowCalculation = {
pipeId: string | number;
direction: PipeFlowDirection;
};
export type FlowOverlayState = {
data: FlowFeature[] | null;
animationFrameId: number | null;
overlay: MapboxOverlay | null;
calculations: Map<string, NormalizedPipeFlowCalculation>;
revision: number;
visible: boolean;
};
export function createFlowOverlayState(): FlowOverlayState {
return { data: null, animationFrameId: null, overlay: null };
return {
calculations: new Map(),
revision: 0,
visible: false
};
}
export async function showFlowOverlay(
map: MapLibreMap,
state: FlowOverlayState,
loadData: () => Promise<FeatureCollection>,
_state: FlowOverlayState,
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
if (!state.data) {
const collection = await loadData();
state.data = collection.features.filter(isFlowFeature);
ensureFlowImages(map, reducedMotion);
ensureFlowLayers(map);
setFlowMockVisibility(map, _state.calculations.size === 0);
_state.visible = true;
setFlowLayerVisibility(map, "visible");
}
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
import("@deck.gl/mapbox"),
import("@deck.gl/layers"),
import("./flow-path-extension")
]);
const extension = new FlowPathExtension();
let renderingError: Error | null = null;
const render = (phase: number) => {
const layer = new PathLayer<FlowFeature>({
id: "workbench-network-flow",
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
data: state.data ?? [],
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
getColor: [15, 118, 110, 210],
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
widthUnits: "pixels",
widthMinPixels: 1,
widthMaxPixels: 5,
capRounded: true,
jointRounded: true,
pickable: false,
parameters: { depthTest: false },
extensions: [extension],
flowPhase: phase
} as never);
state.overlay?.setProps({
layers: [layer],
onError: (error) => {
renderingError = error;
export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) {
_state.visible = false;
setFlowLayerVisibility(map, "none");
}
export async function replacePipeFlowCalculations(
map: MapLibreMap,
state: FlowOverlayState,
records: readonly PipeFlowCalculation[]
): Promise<PipeFlowSummary> {
const revision = ++state.revision;
const { calculations: next, invalid } = normalizePipeFlowCalculations(records);
const changes: FlowStateChange[] = [];
setFlowMockVisibility(map, false);
state.calculations.forEach((_, key) => {
if (!next.has(key)) changes.push({ type: "remove", key });
});
next.forEach((calculation, key) => {
const previous = state.calculations.get(key);
if (
previous?.direction !== calculation.direction
) {
changes.push({ type: "set", key, calculation });
}
});
if (state.visible && changes.length > 0) {
setFlowLayerVisibility(map, "none");
}
await applyFlowStateChanges(map, state, changes, revision);
if (state.visible && revision === state.revision) {
setFlowLayerVisibility(map, "visible");
}
return summarizePipeFlowCalculations(state.calculations.values(), invalid);
}
export function updatePipeFlowCalculation(
map: MapLibreMap,
state: FlowOverlayState,
record: PipeFlowCalculation
): PipeFlowSummary {
const calculation = normalizePipeFlowCalculation(record);
if (!calculation) throw new Error("INVALID_FLOW_CALCULATION");
state.revision += 1;
const key = String(calculation.pipeId);
const previous = state.calculations.get(key);
if (
previous?.direction !== calculation.direction
) {
setPipeFlowState(map, calculation);
state.calculations.set(key, calculation);
}
if (state.visible) setFlowLayerVisibility(map, "visible");
return summarizePipeFlowCalculations(state.calculations.values(), 0);
}
export function clearPipeFlowCalculations(
map: MapLibreMap,
state: FlowOverlayState
) {
return replacePipeFlowCalculations(map, state, []).then((summary) => {
if (state.visible) {
setFlowMockVisibility(map, true);
}
return summary;
});
}
export function getPipeFlowDirection(velocity: number): PipeFlowDirection {
if (!Number.isFinite(velocity) || velocity === 0) return 0;
return velocity < 0 ? -1 : 1;
}
type FlowStateChange =
| { type: "remove"; key: string }
| {
type: "set";
key: string;
calculation: NormalizedPipeFlowCalculation;
};
if (!state.overlay) {
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
map.addControl(overlay);
state.overlay = overlay;
function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) {
const calculations = new Map<string, NormalizedPipeFlowCalculation>();
let invalid = 0;
records.forEach((record) => {
const normalized = normalizePipeFlowCalculation(record);
if (!normalized) {
invalid += 1;
return;
}
calculations.set(String(normalized.pipeId), normalized);
});
return { calculations, invalid };
}
stopFlowAnimation(state);
render(0.2);
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (renderingError) throw renderingError;
if (!reducedMotion) {
const startedAt = performance.now();
const tick = (now: number) => {
render(((now - startedAt) / 1800) % 1);
state.animationFrameId = window.requestAnimationFrame(tick);
function normalizePipeFlowCalculation(
record: PipeFlowCalculation
): NormalizedPipeFlowCalculation | null {
if (
(typeof record.pipeId !== "string" && typeof record.pipeId !== "number")
|| String(record.pipeId).trim().length === 0
|| !Number.isFinite(record.velocity)
) {
return null;
}
return {
pipeId: record.pipeId,
direction: getPipeFlowDirection(record.velocity)
};
state.animationFrameId = window.requestAnimationFrame(tick);
}
function summarizePipeFlowCalculations(
calculations: Iterable<NormalizedPipeFlowCalculation>,
invalid: number
): PipeFlowSummary {
const summary: PipeFlowSummary = {
total: 0,
forward: 0,
reverse: 0,
stopped: 0,
invalid
};
for (const calculation of calculations) {
summary.total += 1;
if (calculation.direction > 0) summary.forward += 1;
else if (calculation.direction < 0) summary.reverse += 1;
else summary.stopped += 1;
}
return summary;
}
function setPipeFlowState(
map: MapLibreMap,
calculation: NormalizedPipeFlowCalculation
) {
map.setFeatureState(
{
source: "pipes",
sourceLayer: SOURCE_LAYERS.pipes,
id: calculation.pipeId
},
{
[FLOW_STATE_DIRECTION]: calculation.direction
}
);
}
async function applyFlowStateChanges(
map: MapLibreMap,
state: FlowOverlayState,
changes: readonly FlowStateChange[],
revision: number
) {
const batchSize = 2_000;
let offset = 0;
await new Promise<void>((resolve) => {
const applyBatch = () => {
if (revision !== state.revision) {
resolve();
return;
}
const end = Math.min(offset + batchSize, changes.length);
for (; offset < end; offset += 1) {
const change = changes[offset];
if (change.type === "remove") {
removePipeFlowState(map, change.key);
state.calculations.delete(change.key);
} else {
setPipeFlowState(map, change.calculation);
state.calculations.set(change.key, change.calculation);
}
}
if (offset >= changes.length) {
resolve();
return;
}
window.requestAnimationFrame(applyBatch);
};
applyBatch();
});
}
function removePipeFlowState(map: MapLibreMap, id: string | number) {
const target = {
source: "pipes",
sourceLayer: SOURCE_LAYERS.pipes,
id
};
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
}
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
map.addImage(
FLOW_FORWARD_IMAGE_ID,
createFlowPatternImage(1, reducedMotion),
{ pixelRatio: 1 }
);
}
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
map.addImage(
FLOW_REVERSE_IMAGE_ID,
createFlowPatternImage(-1, reducedMotion),
{ pixelRatio: 1 }
);
}
}
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
stopFlowAnimation(state);
if (state.overlay) {
map.removeControl(state.overlay as never);
state.overlay = null;
function createFlowPatternImage(
direction: -1 | 1,
reducedMotion: boolean
): StyleImageInterface {
let map: MapLibreMap | null = null;
let lastOffset = Number.NaN;
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
const draw = (offset: number) => {
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
const wrapped = (
x - offset + FLOW_PATTERN_WIDTH
) % FLOW_PATTERN_WIDTH;
const pulse = wrapped < 18
? Math.sin((Math.PI * wrapped) / 18)
: 0;
const alpha = Math.round(255 * pulse * edgeFade);
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
data[index] = 94;
data[index + 1] = 234;
data[index + 2] = 212;
data[index + 3] = alpha;
}
}
};
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
return {
width: FLOW_PATTERN_WIDTH,
height: FLOW_PATTERN_HEIGHT,
data,
onAdd(nextMap) {
map = nextMap;
},
render() {
if (reducedMotion) return false;
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
/ FLOW_PATTERN_DURATION_MS;
const offset = Math.floor(
progress * FLOW_PATTERN_WIDTH * direction
+ FLOW_PATTERN_WIDTH
) % FLOW_PATTERN_WIDTH;
if (offset === lastOffset) {
map?.triggerRepaint();
return false;
}
lastOffset = offset;
draw(offset);
map?.triggerRepaint();
return true;
}
};
}
export function stopFlowAnimation(state: FlowOverlayState) {
if (state.animationFrameId !== null) {
window.cancelAnimationFrame(state.animationFrameId);
state.animationFrameId = null;
const mockDirectionExpression: ExpressionSpecification = [
"case",
[
"==",
["%", ["to-number", ["id"], 0], 2],
0
],
1,
-1
];
const directionExpression = (
direction: -1 | 1,
visibleOpacity: number,
mockVisible = false
): ExpressionSpecification => [
"case",
[
"==",
[
"coalesce",
["feature-state", FLOW_STATE_DIRECTION],
mockVisible ? mockDirectionExpression : 0
],
direction
],
visibleOpacity,
0
];
function ensureFlowLayers(map: MapLibreMap) {
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
map.addLayer({
id: FLOW_LINE_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
layout: flowLineLayout(),
paint: {
"line-pattern": FLOW_FORWARD_IMAGE_ID,
"line-width": 4,
"line-opacity": directionExpression(1, 0.92)
}
}, WORKBENCH_INTERACTION_BEFORE_ID);
}
function isFlowFeature(feature: Feature): feature is FlowFeature {
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
map.addLayer({
id: FLOW_REVERSE_LINE_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
layout: flowLineLayout(),
paint: {
"line-pattern": FLOW_REVERSE_IMAGE_ID,
"line-width": 4,
"line-opacity": directionExpression(-1, 0.92)
}
}, WORKBENCH_INTERACTION_BEFORE_ID);
}
}
function flowLineLayout() {
return {
visibility: "visible" as const,
"line-cap": "round" as const,
"line-join": "round" as const
};
}
function setFlowLayerVisibility(
map: MapLibreMap,
visibility: "visible" | "none"
) {
[
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID
].forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
function setFlowMockVisibility(
map: MapLibreMap,
visible: boolean
) {
const opacityByLayer = [
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
] as const;
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
if (map.getLayer(layerId)) {
map.setPaintProperty(
layerId,
property,
directionExpression(direction, opacity, visible)
);
}
});
}
@@ -1,34 +0,0 @@
import { LayerExtension, type Layer } from "@deck.gl/core";
const flowPathUniforms = {
name: "flowPath",
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
uniformTypes: { phase: "f32" }
} as const;
export class FlowPathExtension extends LayerExtension {
static extensionName = "FlowPathExtension";
static defaultProps = { flowPhase: { type: "number", value: 0 } };
getShaders() {
return {
modules: [flowPathUniforms],
inject: {
"fs:DECKGL_FILTER_COLOR": `
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
color.a *= 0.22 + flowPulse * 0.78;
`
}
};
}
draw(this: Layer) {
const state = this.state as unknown as {
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
};
state.model?.shaderInputs?.setProps({
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
});
}
}
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
iconMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
setLayout(
map,
`${groupId}-symbol`,
"icon-size",
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
if ("iconSize" in visual) {
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
}
}
function scale(value: unknown, multiplier: number): unknown {
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
map.setLayoutProperty(layerId, property, (value ?? null) as never);
}
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
+17 -16
View File
@@ -7,30 +7,31 @@ 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"
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"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
MAX_CONDUIT_FEATURES,
MAX_PIPE_FEATURES,
MAP_FEATURE_ID_FIELDS,
createMapFeatureWfsUrl,
escapeCqlLiteral,
@@ -20,7 +20,7 @@ describe("map feature WFS query", () => {
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", () => {
@@ -28,7 +28,7 @@ describe("map feature WFS query", () => {
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
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", () => {
@@ -39,7 +39,7 @@ describe("map feature WFS query", () => {
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 });
@@ -18,7 +18,7 @@ export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "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;
@@ -75,11 +75,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,9 +49,10 @@ 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", () => {
+9 -8
View File
@@ -1,5 +1,6 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
export const SCADA_ANALYSIS_LAYER_IDS = {
@@ -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 = (
+26 -16
View File
@@ -2,14 +2,13 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13508801.930066336,
3608163.3499832638,
13555650.638648884,
3633685.137885742
]
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
} as const;
export const SOURCE_LAYERS = {
@@ -102,21 +101,27 @@ export const SUPPLY_LAYER_CATALOG = [
}
] as const satisfies readonly SupplyLayerCatalogItem[];
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => layer.id) as Extract<WaterNetworkSourceId, "pipes" | "junctions" | "valves" | "reservoirs" | "scada">[];
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available
).map((layer) => layer.id) as Extract<
WaterNetworkSourceId,
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
>[];
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
export function isAvailableWaterNetworkSourceId(value: string): value is AvailableWaterNetworkSourceId {
export function isAvailableWaterNetworkSourceId(
value: string
): value is AvailableWaterNetworkSourceId {
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
export function createWaterNetworkSources() {
return Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [layer.id, createGeoServerVectorSource(layer.sourceLayer)])
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
layer.id,
createGeoServerVectorSource(layer.sourceLayer)
])
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
}
@@ -135,7 +140,12 @@ function createGeoServerVectorSource(
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {};
const sources: StyleSpecification["sources"] = {
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
}
};
const layers: StyleSpecification["layers"] = [
{
id: "background",
@@ -185,8 +195,8 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
},
paint: {
"raster-opacity": 0.86,
"raster-saturation": -0.16,
"raster-contrast": 0.02
"raster-saturation": -0.3,
"raster-contrast": 0.1
}
});
}
+1 -11
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createValueLabelCollection, formatFeatureValue, getNumericFeatureProperties } from "./value-label";
import { getNumericFeatureProperties } from "./value-label";
describe("workbench value labels", () => {
const feature = {
@@ -11,14 +11,4 @@ describe("workbench value labels", () => {
it("lists finite numeric properties only", () => {
expect(getNumericFeatureProperties(feature.properties)).toEqual([["depth", 2], ["diameter", 600.125]]);
});
it("formats precision and unit with hard limits", () => {
expect(formatFeatureValue(12.3456, 2, "mm")).toBe("12.35 mm");
expect(formatFeatureValue(12.3456, 8, "")).toBe("12.346");
});
it("creates a single controlled label feature", () => {
expect(createValueLabelCollection(feature, "diameter", 1, "mm").features[0]?.properties?.label).toBe("600.1 mm");
expect(() => createValueLabelCollection(feature, "name", 1)).toThrow("INVALID_VALUE_PROPERTY");
});
});
+135 -32
View File
@@ -1,7 +1,127 @@
import type { Feature, FeatureCollection, Geometry } from "geojson";
import type {
ExpressionSpecification,
Map as MapLibreMap,
SymbolLayerSpecification
} from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SOURCE_LAYERS } from "./sources";
export const VALUE_LABEL_SOURCE_ID = "workbench-value-label";
export const VALUE_LABEL_LAYER_IDS = ["workbench-value-label-point", "workbench-value-label-line"] as const;
export const VALUE_LABEL_LAYER_IDS = {
pipes: "workbench-value-label-pipes",
junctions: "workbench-value-label-junctions",
valves: "workbench-value-label-valves",
reservoirs: "workbench-value-label-reservoirs"
} as const;
export type ValueLabelSourceId = keyof typeof VALUE_LABEL_LAYER_IDS;
export type ValueLabelStyle = {
sourceId: ValueLabelSourceId;
property: string;
precision: number;
unit: string;
showValue: boolean;
showId: boolean;
textSize: number;
textColor: string;
};
const LABEL_SOURCE_IDS = Object.keys(VALUE_LABEL_LAYER_IDS) as ValueLabelSourceId[];
export function createValueLabelLayers(): SymbolLayerSpecification[] {
return LABEL_SOURCE_IDS.map((sourceId) => {
const line = sourceId === "pipes";
return {
id: VALUE_LABEL_LAYER_IDS[sourceId],
type: "symbol",
source: sourceId,
"source-layer": SOURCE_LAYERS[sourceId],
minzoom: sourceId === "junctions" ? 15 : 10,
layout: {
visibility: "none",
"symbol-placement": line ? "line-center" : "point",
"text-field": "",
"text-size": 12,
"text-offset": line ? [0, 0] : [0, -1.35],
"text-anchor": line ? "center" : "bottom",
"text-padding": 5,
"text-allow-overlap": false,
"text-ignore-placement": false,
"text-keep-upright": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-width": 2,
"text-halo-blur": 0.4,
"text-opacity": 0.96
}
};
});
}
export function applyValueLabelStyle(
map: MapLibreMap,
style: ValueLabelStyle
) {
if (!validateValueLabelStyle(style)) {
throw new Error("INVALID_VALUE_LABEL_STYLE");
}
const layerId = VALUE_LABEL_LAYER_IDS[style.sourceId];
if (!map.getLayer(layerId)) {
throw new Error("VALUE_LABEL_LAYER_UNAVAILABLE");
}
map.setFilter(
layerId,
style.showValue ? ["has", style.property] : null
);
map.setLayoutProperty(
layerId,
"text-field",
createValueLabelTextExpression(style)
);
map.setLayoutProperty(layerId, "text-size", style.textSize);
map.setPaintProperty(layerId, "text-color", style.textColor);
map.setLayoutProperty(layerId, "visibility", "visible");
}
export function clearValueLabels(
map: MapLibreMap,
targetSourceId?: ValueLabelSourceId
) {
const sourceIds = targetSourceId ? [targetSourceId] : LABEL_SOURCE_IDS;
sourceIds.forEach((sourceId) => {
const layerId = VALUE_LABEL_LAYER_IDS[sourceId];
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", "none");
}
});
}
function createValueLabelTextExpression(
style: ValueLabelStyle
): ExpressionSpecification {
const idExpression: ExpressionSpecification = [
"to-string",
["coalesce", ["get", "id"], ["id"]]
];
const valueExpression: ExpressionSpecification = [
"concat",
[
"number-format",
["to-number", ["get", style.property], 0],
{
"min-fraction-digits": style.precision,
"max-fraction-digits": style.precision
}
],
style.unit ? ` ${style.unit}` : ""
];
if (style.showId && style.showValue) {
return ["concat", idExpression, " · ", valueExpression];
}
return style.showId ? idExpression : valueExpression;
}
export function getNumericFeatureProperties(properties: Record<string, unknown> | null | undefined) {
return Object.entries(properties ?? {})
@@ -9,33 +129,16 @@ export function getNumericFeatureProperties(properties: Record<string, unknown>
.sort(([a], [b]) => a.localeCompare(b));
}
export function formatFeatureValue(value: number, precision: number, unit = "") {
const safePrecision = Math.min(3, Math.max(0, Math.trunc(precision)));
const safeUnit = unit.trim().slice(0, 16);
return `${value.toFixed(safePrecision)}${safeUnit ? ` ${safeUnit}` : ""}`;
}
export function createValueLabelCollection(
feature: Feature,
property: string,
precision: number,
unit = ""
): FeatureCollection {
const value = feature.properties?.[property];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("INVALID_VALUE_PROPERTY");
}
return {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: feature.geometry as Geometry,
properties: { label: formatFeatureValue(value, precision, unit) }
}]
};
}
export function createEmptyValueLabelCollection(): FeatureCollection {
return { type: "FeatureCollection", features: [] };
function validateValueLabelStyle(style: ValueLabelStyle) {
return style.sourceId in VALUE_LABEL_LAYER_IDS
&& /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(style.property)
&& Number.isInteger(style.precision)
&& style.precision >= 0
&& style.precision <= 3
&& style.unit.length <= 16
&& (style.showValue || style.showId)
&& Number.isFinite(style.textSize)
&& style.textSize >= 10
&& style.textSize <= 18
&& /^#[\dA-F]{6}$/i.test(style.textColor);
}
@@ -1,6 +1,11 @@
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
FLOW_LINE_LAYER_ID,
createFlowOverlayState,
showFlowOverlay
} from "./flow-overlay";
import { WorkbenchMapController } from "./workbench-map-controller";
describe("WorkbenchMapController", () => {
@@ -10,7 +15,8 @@ describe("WorkbenchMapController", () => {
await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true });
controller.clearHighlight();
const clearHighlight = controller.clearHighlight;
clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
});
@@ -23,6 +29,201 @@ describe("WorkbenchMapController", () => {
expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 }));
});
it("zooms to a feature without changing its highlight state", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.zoomToFeature({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("queries GeoServer WFS directly when no feature adapter is supplied", async () => {
const map = createMap();
const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify(pointCollection), {
status: 200,
headers: { "Content-Type": "application/json" }
}));
vi.stubGlobal("fetch", fetchMock);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows");
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
expect(map.setFeatureState).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("renders flow from the complete pipes vector source", async () => {
const map = createMap();
const layers = new Map<string, unknown>();
map.getLayer.mockImplementation((id: string) => layers.get(id));
map.addLayer.mockImplementation((layer: { id: string }) => {
layers.set(layer.id, layer);
return map as never;
});
vi.stubGlobal("window", {
matchMedia: () => ({ matches: true }),
requestAnimationFrame: vi.fn(),
cancelAnimationFrame: vi.fn()
});
await showFlowOverlay(map as unknown as MapLibreMap, createFlowOverlayState(), true);
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat"
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
vi.unstubAllGlobals();
});
it("updates signed pipe velocity and exposes direction counts", () => {
const map = createMap();
const controller = createController(map, pointCollection);
controller.updatePipeFlowCalculation({ pipeId: "P-1", velocity: -1.8 });
expect(map.setFeatureState).toHaveBeenCalledWith(
expect.objectContaining({
source: "pipes",
sourceLayer: "geo_pipes_mat",
id: "P-1"
}),
{ flowDirection: -1 }
);
expect(controller.getSnapshot().flowSummary).toMatchObject({
total: 1,
forward: 0,
reverse: 1,
stopped: 0
});
});
it("replaces complete pipe calculation snapshots", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.replacePipeFlowCalculations([
{ pipeId: "P-1", velocity: 2 },
{ pipeId: "P-2", velocity: 0 },
{ pipeId: "", velocity: 4 }
]);
expect(controller.getSnapshot()).toMatchObject({
pending: false,
flowSummary: {
total: 2,
forward: 1,
reverse: 0,
stopped: 1,
invalid: 1
}
});
});
it("keeps MVT labels per group and supports group or global clearing", async () => {
const map = createMap();
map.getLayer.mockImplementation((id: string) => id.startsWith("workbench-value-label-") ? {} as never : undefined);
const controller = createController(map, pointCollection);
await controller.showValueLabel({
sourceId: "pipes",
property: "diameter",
precision: 0,
unit: "mm",
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
map.setLayoutProperty.mockClear();
await controller.showValueLabel({
sourceId: "junctions",
property: "pressure",
precision: 1,
unit: "m",
showValue: true,
showId: true,
textSize: 13,
textColor: "#0F172A"
});
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"text-field",
expect.arrayContaining(["concat"])
);
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"visibility",
"visible"
);
expect(map.setFilter).toHaveBeenCalledWith(
"workbench-value-label-junctions",
["has", "pressure"]
);
expect(controller.getSnapshot().valueLabels.junctions).toMatchObject({
sourceId: "junctions",
property: "pressure",
showId: true
});
expect(controller.getSnapshot().valueLabels.pipes).toMatchObject({
sourceId: "pipes",
property: "diameter"
});
expect(map.setLayoutProperty).not.toHaveBeenCalledWith(
"workbench-value-label-pipes",
"visibility",
"none"
);
controller.clearValueLabel("junctions");
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-junctions",
"visibility",
"none"
);
expect(controller.getSnapshot().valueLabels).toEqual({
pipes: expect.objectContaining({ property: "diameter" })
});
const clearValueLabel = controller.clearValueLabel;
clearValueLabel();
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"workbench-value-label-pipes",
"visibility",
"none"
);
expect(controller.getSnapshot().valueLabels).toEqual({});
});
it("applies icon scaling to symbol-backed point groups", () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
const controller = createController(map, pointCollection);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.8 });
expect(map.setLayoutProperty).toHaveBeenCalledWith(
"valves-symbol",
"icon-size",
expect.arrayContaining(["*", expect.anything(), 1.8])
);
});
it("does not clear the last valid highlight when a target is missing", async () => {
const map = createMap();
let collection = pointCollection;
@@ -40,11 +241,36 @@ describe("WorkbenchMapController", () => {
expect(map.removeFeatureState).not.toHaveBeenCalled();
});
it("reports SCADA analysis as unavailable for the water-network data source", async () => {
it("renders SCADA analysis through the registered GeoJSON source", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND");
expect(controller.getSnapshot().errorCode).toBe("SCADA_FEATURES_NOT_FOUND");
const source = { setData: vi.fn() };
map.getSource.mockReturnValue(source);
const controller = createController(map, {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { sensor_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
});
expect(source.setData).toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({
errorCode: null,
pending: false,
scadaAnalysis: {
rendered_ids: ["MP01"],
fitted: true
}
});
});
});
@@ -52,9 +278,15 @@ const pointCollection: FeatureCollection = { type: "FeatureCollection", features
const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "P-1" } }] };
function createMap() {
const images = new Set<string>();
return {
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn()
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
})
};
}
@@ -1,4 +1,4 @@
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import {
@@ -9,11 +9,29 @@ import {
type LayerGroupId,
type LayerGroupStylePatch
} from "./layer-group-style";
import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay";
import {
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection,
createValueLabelCollection
clearPipeFlowCalculations as clearFlowCalculations,
createFlowOverlayState,
hideFlowOverlay,
replacePipeFlowCalculations as replaceFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation as updateFlowCalculation,
type FlowOverlayState,
type PipeFlowCalculation,
type PipeFlowSummary
} from "./flow-overlay";
import {
MAX_PIPE_FEATURES,
MAX_MAP_FEATURE_IDS,
createMapFeatureWfsUrl,
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "./map-feature-query";
import {
applyValueLabelStyle,
clearValueLabels,
type ValueLabelSourceId,
type ValueLabelStyle
} from "./value-label";
import {
SCADA_ANALYSIS_SOURCE_ID,
@@ -32,25 +50,32 @@ export type WorkbenchMapErrorCode =
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
| "INVALID_FLOW_CALCULATION"
| "INVALID_STYLE_PATCH";
export type WorkbenchMapControllerState = {
target: FeatureTarget | null;
valueLabels: Partial<Record<ValueLabelSourceId, ValueLabelStyle>>;
pending: boolean;
errorCode: WorkbenchMapErrorCode | null;
flowVisible: boolean;
flowSummary: PipeFlowSummary;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string };
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
export type WorkbenchMapCommands = {
zoomToFeature: (target: FeatureTarget) => Promise<void>;
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
clearValueLabel: () => void;
replacePipeFlowCalculations: (records: readonly PipeFlowCalculation[]) => Promise<PipeFlowSummary | null>;
updatePipeFlowCalculation: (record: PipeFlowCalculation) => PipeFlowSummary | null;
clearPipeFlowCalculations: () => Promise<void>;
showValueLabel: (request: ValueLabelRequest) => Promise<void>;
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
@@ -67,11 +92,21 @@ export type WorkbenchMapControllerOptions = {
};
export class WorkbenchMapController implements WorkbenchMapCommands {
private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null };
private state: WorkbenchMapControllerState = {
target: null,
valueLabels: {},
pending: false,
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
};
private listeners = new Set<() => void>();
private highlightedTarget: FeatureTarget | null = null;
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private scadaAnalysisRevision = 0;
constructor(private readonly options: WorkbenchMapControllerOptions) {}
@@ -83,22 +118,48 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
this.moveCameraToFeature(map, feature);
}
async locateAndHighlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
if (!this.moveCameraToFeature(map, feature)) return;
this.setHighlight(target);
}
private moveCameraToFeature(map: MapLibreMap, feature: Feature) {
const coordinates = collectCoordinates(feature.geometry);
if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND");
if (!coordinates.length) {
this.setError("FEATURE_NOT_FOUND");
return false;
}
const duration = this.options.reducedMotion?.() ? 0 : 600;
if (feature.geometry.type === "Point") {
map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 });
map.easeTo({
center: coordinates[0] as [number, number],
zoom: 18,
padding: this.options.getPadding(),
duration
});
} else {
const bounds = coordinates.reduce(
(acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 });
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.options.getPadding(),
maxZoom: 18,
duration
});
}
this.setHighlight(target);
this.patchState({ errorCode: null });
return true;
}
async highlight(target: FeatureTarget) {
@@ -106,14 +167,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
if (feature) this.setHighlight(target);
}
clearHighlight() {
clearHighlight = () => {
const map = this.options.getMap();
if (map && this.highlightedTarget) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
this.patchState({ target: null, errorCode: null });
}
};
async setFlowVisible(visible: boolean) {
const map = this.requireMap();
@@ -128,7 +189,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
await showFlowOverlay(
map,
this.flowState,
() => this.fetchFeatures("pipes"),
this.options.reducedMotion?.()
);
this.patchState({ pending: false, flowVisible: true });
@@ -138,25 +198,96 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
}
async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) {
if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH");
const feature = await this.resolveTarget(target);
async replacePipeFlowCalculations(
records: readonly PipeFlowCalculation[]
): Promise<PipeFlowSummary | null> {
const map = this.requireMap();
if (!feature || !map) return;
if (!map) return null;
const revision = ++this.flowUpdateRevision;
this.patchState({ pending: true, errorCode: null });
try {
(map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createValueLabelCollection(feature, property, precision, unit)
);
this.patchState({ target, errorCode: null });
const summary = await replaceFlowCalculations(map, this.flowState, records);
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
}
return summary;
} catch {
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
}
return null;
}
}
updatePipeFlowCalculation(
record: PipeFlowCalculation
): PipeFlowSummary | null {
const map = this.requireMap();
if (!map) return null;
this.flowUpdateRevision += 1;
try {
const summary = updateFlowCalculation(map, this.flowState, record);
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
return summary;
} catch {
this.patchState({
pending: false,
errorCode: "INVALID_FLOW_CALCULATION"
});
return null;
}
}
async clearPipeFlowCalculations() {
const map = this.requireMap();
if (!map) return;
const revision = ++this.flowUpdateRevision;
this.patchState({ pending: true, errorCode: null });
try {
await clearFlowCalculations(map, this.flowState);
if (revision === this.flowUpdateRevision) {
this.patchState({
pending: false,
flowSummary: createEmptyPipeFlowSummary(),
errorCode: null
});
}
} catch {
if (revision === this.flowUpdateRevision) {
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
}
}
}
async showValueLabel(request: ValueLabelRequest) {
const map = this.requireMap();
if (!map) return;
const style: ValueLabelStyle = { ...request, unit: request.unit ?? "" };
try {
applyValueLabelStyle(map, style);
this.patchState({
valueLabels: {
...this.state.valueLabels,
[style.sourceId]: style
},
errorCode: null
});
} catch {
this.setError("INVALID_STYLE_PATCH");
}
}
clearValueLabel() {
clearValueLabel = (sourceId?: ValueLabelSourceId) => {
const map = this.options.getMap();
(map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection());
if (map) clearValueLabels(map, sourceId);
if (!sourceId) {
this.patchState({ valueLabels: {} });
return;
}
const valueLabels = { ...this.state.valueLabels };
delete valueLabels[sourceId];
this.patchState({ valueLabels });
};
applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const map = this.requireMap();
@@ -173,12 +304,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
}
async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise<ScadaAnalysisRenderResult> {
void items;
void signal;
this.setError("SCADA_FEATURES_NOT_FOUND");
throw new Error("SCADA_FEATURES_NOT_FOUND");
/*
async renderScadaAnalysis(
items: ScadaAnalysisItem[],
signal?: AbortSignal
): Promise<ScadaAnalysisRenderResult> {
const revision = ++this.scadaAnalysisRevision;
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
@@ -189,21 +318,36 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
let sourceCollection: FeatureCollection;
try {
sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal);
sourceCollection = await this.fetchFeatures(
"scada",
items.map((item) => item.sensor_id),
signal
);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
}
throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED");
throw new Error(
revision === this.scadaAnalysisRevision
? "WFS_UNAVAILABLE"
: "ACTION_SUPERSEDED"
);
}
if (revision !== this.scadaAnalysisRevision) {
throw new Error("ACTION_SUPERSEDED");
}
if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED");
const next = createScadaAnalysisCollection(sourceCollection, items);
if (next.collection.features.length === 0) {
this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" });
this.patchState({
pending: false,
errorCode: "SCADA_FEATURES_NOT_FOUND"
});
throw new Error("SCADA_FEATURES_NOT_FOUND");
}
const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined;
const source = map.getSource(
SCADA_ANALYSIS_SOURCE_ID
) as GeoJSONSource | undefined;
if (!source) {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
@@ -216,9 +360,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true };
this.patchState({ pending: false, errorCode: null, scadaAnalysis: result });
const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates);
const result: ScadaAnalysisRenderResult = {
...next.result,
fitted: true
};
this.patchState({
pending: false,
errorCode: null,
scadaAnalysis: result
});
const coordinates = next.collection.features.map(
(feature) => feature.geometry.coordinates
);
if (coordinates.length === 1) {
map.easeTo({
center: [coordinates[0][0], coordinates[0][1]],
@@ -236,14 +390,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]]
],
{
padding: this.options.getPadding(),
maxZoom: 18,
duration: this.options.reducedMotion?.() ? 0 : 600
});
}
);
}
return result;
*/
}
clearScadaAnalysis() {
@@ -256,19 +415,31 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return { cleared: true as const };
}
resetAll() {
resetAll = () => {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
const map = this.options.getMap();
if (map) {
hideFlowOverlay(map, this.flowState);
this.flowUpdateRevision += 1;
void clearFlowCalculations(map, this.flowState);
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
}
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
}
this.patchState({
target: null,
valueLabels: {},
pending: false,
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
});
};
destroy() {
this.flowUpdateRevision += 1;
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear();
@@ -299,15 +470,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
const response = await fetch("/api/map-features", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId, featureIds }),
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
headers: { Accept: "application/geo+json, application/json" },
signal
});
const body = await response.json();
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
return body as FeatureCollection;
if (!response.ok) throw new Error("WFS_UNAVAILABLE");
const maxFeatures = featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES;
const collection = normalizeMapFeatureCollection(await response.json(), maxFeatures);
if (!collection) throw new Error("WFS_UNAVAILABLE");
return collection;
}
private setHighlight(target: FeatureTarget) {
@@ -316,7 +489,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
this.highlightedTarget = target;
this.clearValueLabel();
this.patchState({ target, errorCode: null });
}
@@ -339,6 +511,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
}
function createEmptyPipeFlowSummary(): PipeFlowSummary {
return { total: 0, forward: 0, reverse: 0, stopped: 0, invalid: 0 };
}
function toFeatureStateTarget(target: FeatureTarget) {
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionItem } from "../types";
import {
canCenterTimelineBesideMapScale,
clusterOperationalEvents,
createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout
} from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1",
kind: "work_order",
code: "WO-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整",
summary: "调整阀门",
status: "running",
riskLevel: "attention",
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
};
describe("operational timeline model", () => {
it("does not reserve workspace layout space for the floating timeline", () => {
expect(OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight).toBe(0);
});
it("creates linked plan and actual events for active work orders", () => {
const events = createOperationalEvents([workOrder]);
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
actual: false
});
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "诊断",
summary: "正常",
status: "completed",
riskLevel: "normal",
updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = {
...normal,
id: "condition-error",
status: "error",
riskLevel: "critical"
} satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
});
it("aggregates nearby events into ten minute lane buckets", () => {
const events = createOperationalEvents([workOrder]);
const nearbyPlan = {
...events[0],
id: "plan-nearby",
cursorTime: "2026-08-19T10:05:00.000Z"
};
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
expect(clusters).toHaveLength(2);
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
});
it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => {
expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true);
expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false);
expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true);
});
it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => {
expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({
scaleBottom: 0,
timelineBottom: 24
});
expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({
scaleBottom: 0,
timelineBottom: 56
});
});
});
@@ -0,0 +1,242 @@
import type { FeatureTarget } from "../map/workbench-map-controller";
import type {
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledWorkOrderItem
} from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = {
id: string;
title: string;
description: string;
lane: OperationalEventLane;
status: OperationalEventStatus;
importance: OperationalEventImportance;
plannedTime: string | null;
actualTime: string | null;
cursorTime: string;
correlationId: string | null;
scheduleId: string;
conditionId: string | null;
sourceLabel: string;
mapTargets: FeatureTarget[];
};
export type OperationalEventCluster = {
id: string;
lane: OperationalEventLane;
bucketStart: number;
events: OperationalEvent[];
};
export const OPERATIONAL_TIMELINE_LAYOUT = {
compact: { height: 88, maxWidth: 980 },
expanded: { height: 240, maxWidth: 1180 },
summary: { height: 52, maxWidth: 760 },
mobileSummaryHeight: 48,
mapScaleSafeWidth: 520,
floatingGap: 48,
edgeInset: 24,
scaleCollisionOffset: 56,
overlayReservedHeight: 0
} as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
export type TimelineScaleLayout = {
scaleBottom: number;
timelineBottom: number;
};
export function canCenterTimelineBesideMapScale(
businessWorkspaceWidth: number,
mode: OperationalTimelineMode,
visible: boolean
) {
if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return (
businessWorkspaceWidth >=
layout.maxWidth +
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
);
}
export function resolveTimelineScaleLayout(
businessWorkspaceWidth: number,
mode: OperationalTimelineMode,
visible: boolean
): TimelineScaleLayout {
const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale(
businessWorkspaceWidth,
mode,
visible
);
return {
scaleBottom: 0,
timelineBottom:
visible && !scaleClearsCenteredTimeline
? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset
: OPERATIONAL_TIMELINE_LAYOUT.edgeInset
};
}
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items
.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
)
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
}
export function clusterOperationalEvents(
events: OperationalEvent[],
bucketMinutes = 10
): OperationalEventCluster[] {
const bucketMs = bucketMinutes * 60_000;
const clusters = new Map<string, OperationalEventCluster>();
events.forEach((event) => {
const timestamp = Date.parse(event.cursorTime);
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
const key = `${event.lane}-${bucketStart}`;
const cluster = clusters.get(key) ?? {
id: key,
lane: event.lane,
bucketStart,
events: []
};
cluster.events.push(event);
clusters.set(key, cluster);
});
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
}
export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") {
return [];
}
return [
{
id: `exception-${item.id}`,
title: item.title,
description: item.report?.conclusion ?? item.summary,
lane: "exception",
status: item.status === "error" ? "failed" : "delayed",
importance: item.status === "error" ? "critical" : "important",
plannedTime: item.scheduledAt,
actualTime: item.scheduledAt,
cursorTime: item.scheduledAt,
correlationId: item.sessionId,
scheduleId: item.taskId,
conditionId: item.id,
sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item)
}
];
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const planEvent: OperationalEvent = {
id: `plan-${item.id}`,
title: item.title,
description: `${item.location} · ${item.assignee}`,
lane: "plan",
status: "planned",
importance: item.priority === "urgent" ? "important" : "normal",
plannedTime: item.scheduledAt,
actualTime: null,
cursorTime: item.scheduledAt,
correlationId: item.code,
scheduleId: item.id,
conditionId: item.id,
sourceLabel: item.source,
mapTargets: getWorkOrderMapTargets(item)
};
if (item.status === "pending") {
return [planEvent];
}
const start = Date.parse(item.scheduledAt);
const actualTime = new Date(
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
).toISOString();
const actualEvent: OperationalEvent = {
...planEvent,
id: `actual-${item.id}`,
lane: "actual",
status: "executed",
actualTime,
cursorTime: actualTime,
description:
item.status === "running"
? `${item.assignee}正在执行,位置:${item.location}`
: `${item.assignee}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
}
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
if (item.taskId === "scada-diagnosis") {
return [{ sourceId: "scada", featureId: "SCADA-07" }];
}
if (item.taskId === "pump-energy") {
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
}
return [];
}
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
if (item.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
}
if (item.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
}
return [];
}
function extractAssetId(location: string) {
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
}
@@ -0,0 +1,575 @@
import {
CalendarClock,
Check,
Clock3,
OctagonAlert,
PanelBottomClose,
PanelBottomOpen,
RotateCcw,
TriangleAlert
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import {
clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode
} from "./operational-timeline-model";
type OperationalTimelineProps = {
date: string;
cursorTime: string;
events: OperationalEvent[];
mode: OperationalTimelineMode;
selectedEventId: string | null;
mobile?: boolean;
onModeChange?: (mode: OperationalTimelineMode) => void;
onSelectEvent: (event: OperationalEvent) => void;
onClearSelection: () => void;
onReturnNow: () => void;
};
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "exception", label: "异常" }
];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
events,
mode,
selectedEventId,
mobile = false,
onModeChange,
onSelectEvent,
onClearSelection,
onReturnNow
}: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events);
const visibleEvents = filterOperationalEvents(events, eventVisibility);
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
const sizeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
if (mode === "summary" && !mobile) {
return (
<motion.section
aria-label="运行时间轴摘要"
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
initial={false}
animate={{ height }}
transition={sizeTransition}
>
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
<span className="text-xs font-semibold">{formatDate(date)}</span>
<span className="h-4 w-px bg-slate-300" />
<TimelineSummary summary={summary} />
{selectedEvent ? (
<button
type="button"
onClick={onClearSelection}
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
>
已选 {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : (
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
)}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section>
);
}
return (
<TooltipProvider delayDuration={180}>
<motion.section
aria-label="运行时间轴"
className={cn(
"flex min-h-0 flex-col overflow-hidden text-slate-900",
mobile
? "surface-reading h-full rounded-t-xl border-t"
: "acrylic-panel rounded-2xl border"
)}
initial={false}
animate={{ height }}
transition={sizeTransition}
>
<div
className={cn(
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
mode === "compact" && !mobile ? "h-10" : "h-12"
)}
>
<div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2">
<p
className={cn(
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
((mode === "compact" && !mobile) || mobile) && "hidden"
)}
>
运行时间轴
</p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
{!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? (
<button
type="button"
onClick={onClearSelection}
title="清除当前事件选择"
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
<AnimatePresence initial={false} mode="wait">
{mode === "expanded" || mobile ? (
<motion.div
key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
<span />
{LANES.map((lane) => (
<span key={lane.id} className="flex items-center justify-center font-semibold">
{lane.label}
</span>
))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
<TimeTicks />
{LANES.map((lane) => (
<TimelineLane
key={lane.id}
lane={lane.id}
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
style={{ left: `${cursorPosition}%` }}
>
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
</div>
</div>
</motion.div>
) : (
<motion.div
key="compact-timeline"
className="relative min-h-0 flex-1 px-3"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<CompactTimeTicks />
<div className="absolute bottom-2 left-3 right-3 top-4">
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
compact
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
style={{ left: `${cursorPosition}%` }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.section>
</TooltipProvider>
);
}
function TimelineLane({
lane,
clusters,
selectedEventId,
onSelectEvent
}: {
lane: OperationalEventLane;
clusters: OperationalEventCluster[];
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
return (
<div className="relative border-t border-slate-200" data-lane={lane}>
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
</div>
);
}
function TimelineMarker({
cluster,
compact = false,
markerIndex,
selectedEventId,
onSelectEvent
}: {
cluster: OperationalEventCluster;
compact?: boolean;
markerIndex: number;
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
const event = pickClusterEvent(cluster.events);
const selected = cluster.events.some((item) => item.id === selectedEventId);
const position = getDayPosition(event.cursorTime);
const exception = event.lane === "exception";
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
onClick={() => onSelectEvent(event)}
className={cn(
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
compact ? "h-2 w-2" : "h-5 w-5",
event.lane === "plan" &&
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception &&
event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
event.importance === "critical" && !compact && "h-6 w-6",
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
)}
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
>
{!compact ? <OperationalEventGlyph event={event} /> : null}
{cluster.events.length > 1 && !compact ? (
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
{cluster.events.length}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className={cn(
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
)}
>
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)}
</span>
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
if (event.status === "failed") {
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "exception") {
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "actual") {
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
}
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "异常";
if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span>
计划 <strong className="text-blue-700">{summary.planned}</strong>
</span>
<span>
执行 <strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
关注 <strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
异常 <strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div>
);
}
function TimelineActions({
mode,
onModeChange,
onReturnNow
}: {
mode: OperationalTimelineMode;
onModeChange?: (mode: OperationalTimelineMode) => void;
onReturnNow: () => void;
}) {
return (
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button
type="button"
title="返回现在"
aria-label="返回现在"
onClick={onReturnNow}
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
>
<RotateCcw size={13} aria-hidden="true" />
<span className="hidden sm:inline">现在</span>
</button>
{onModeChange ? (
<>
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
<button
type="button"
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
>
{mode === "expanded" ? (
<PanelBottomClose size={14} aria-hidden="true" />
) : (
<PanelBottomOpen size={14} aria-hidden="true" />
)}
<span>{mode === "expanded" ? "收起" : "展开"}</span>
</button>
</>
) : null}
</div>
);
}
function TimelineLegend({
visibility,
onVisibilityChange
}: {
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[item.id]: !visible
})
}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25"
)}
>
<LegendSwatch category={item.id} />
</button>
);
})}
</div>
);
}
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
return (
<span
className={cn(
"block h-3.5 w-3.5 shrink-0 border",
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);
}
function TimeTicks() {
return (
<div className="relative text-[9px] text-slate-600">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function CompactTimeTicks() {
return (
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort(
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
)[0];
}
function importanceRank(value: OperationalEvent["importance"]) {
return value === "critical" ? 2 : value === "important" ? 1 : 0;
}
function getDayPosition(value: string) {
const date = new Date(value);
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
}
function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function formatDate(value: string) {
const [year, month, day] = value.split("-");
return `${year}.${month}.${day}`;
}
+8 -4
View File
@@ -32,6 +32,10 @@ export type WorkbenchUser = {
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply";
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
@@ -71,12 +75,12 @@ export type ScheduledConditionReport = {
}[];
};
type ScheduledConditionBase = {
type ScheduledConditionBase<TStatus extends ScheduledConditionItemStatus> = {
id: string;
scheduledAt: string;
title: string;
summary: string;
status: ScheduledConditionStatus;
status: TStatus;
riskLevel: ScheduledConditionRiskLevel;
updatedAt: number;
detail?: string;
@@ -84,7 +88,7 @@ type ScheduledConditionBase = {
durationMinutes?: number;
};
export type ScheduledConditionRecord = ScheduledConditionBase & {
export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledConditionStatus> & {
kind: "condition";
taskId: ScheduledConditionTaskId;
sessionId: string;
@@ -95,7 +99,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & {
report?: ScheduledConditionReport;
};
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
kind: "work_order";
code: string;
source: string;
@@ -1,11 +1,12 @@
import { getRunningWorkflowDefinition } from "../data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
const statusLabels: Record<ScheduledConditionStatus, string> = {
const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
warning: "关注",
error: "异常",
pending: "预约"
@@ -0,0 +1,272 @@
import type { AnalysisArtifact } from "./workspace-model";
type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
id,
type: "diagnosis",
lifecycle: "reviewed",
revision: 3,
title: "北辰分区压力异常诊断",
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
scope: "北辰供水分区 · 最近 6 小时",
confidence: "92.6%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
{
id: "pressure-metrics",
kind: "metric-grid",
title: "诊断摘要",
span: 12,
metrics: [
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
]
},
{
id: "pressure-trend",
kind: "chart",
title: "关键节点压力曲线",
description: "BC-P-014 在 09:40 后持续低于调度下限",
span: 7,
chartType: "line",
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
unit: "MPa",
series: [
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
]
},
{
id: "pressure-ranking",
kind: "chart",
title: "异常偏差排名",
description: "按相对调度下限的压力偏差排序",
span: 5,
chartType: "bar",
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
unit: "%",
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
},
{
id: "pressure-evidence",
kind: "data-table",
title: "证据链",
description: "对诊断结论贡献最高的观测证据",
span: 12,
columns: [
{ key: "time", label: "时间" },
{ key: "source", label: "数据源" },
{ key: "finding", label: "观测" },
{ key: "impact", label: "影响" },
{ key: "confidence", label: "置信度", numeric: true }
],
rows: [
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
]
}
],
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
actions: [
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
]
});
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
id,
type: "metric_analysis",
lifecycle: "draft",
revision: 2,
title: "供水服务分区平衡分析",
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
scope: "六个服务分区 · 今日峰值预测",
confidence: "88.4%",
generatedAt,
analyticalTimeRange: {
start: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(0, 0, 0, 0)).toISOString(),
end: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(23, 59, 59, 999)).toISOString()
},
preferredView: "focus",
mapRelation: "none",
blocks: [
{
id: "zone-metrics",
kind: "metric-grid",
title: "分区态势",
span: 12,
metrics: [
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
]
},
{
id: "zone-balance",
kind: "chart",
title: "分区供需对比",
description: "北辰与东丽当前需求已接近可用供水能力",
span: 7,
chartType: "bar",
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
unit: "万m³/d",
series: [
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
]
},
{
id: "zone-risk",
kind: "data-table",
title: "调配优先级",
description: "按负荷率、连续供水和通道可用性综合排序",
span: 5,
columns: [
{ key: "zone", label: "分区" },
{ key: "load", label: "负荷率", numeric: true },
{ key: "risk", label: "风险" },
{ key: "action", label: "建议" }
],
rows: [
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
]
},
{
id: "zone-note",
kind: "narrative",
title: "Agent 结论",
span: 12,
paragraphs: [
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
]
}
],
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
actions: [
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
]
});
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
id,
type: "simulation",
lifecycle: "published",
revision: 5,
title: "低压事件调度处置流程",
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
scope: "北辰低压事件 · 30 分钟模拟",
confidence: "90.8%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
{
id: "flow-metrics",
kind: "metric-grid",
title: "执行状态",
span: 12,
metrics: [
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
]
},
{
id: "dispatch-process",
kind: "process-flow",
title: "处置路径",
description: "所有调度动作均需在执行前完成权限和现场状态确认",
span: 7,
nodes: [
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
]
},
{
id: "dispatch-checklist",
kind: "data-table",
title: "执行清单",
description: "当前阶段需要完成的人员与系统确认",
span: 5,
columns: [
{ key: "owner", label: "责任方" },
{ key: "item", label: "确认项" },
{ key: "state", label: "状态" }
],
rows: [
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
]
},
{
id: "dispatch-progress",
kind: "chart",
title: "处置前后压力预测",
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
span: 12,
chartType: "line",
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
unit: "MPa",
series: [
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
]
}
],
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
actions: [
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
]
});
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date();
return factory(
`analysis-${now.getTime().toString(36)}-${instance}`,
now.toLocaleTimeString("zh-CN", { hour12: false })
);
}
function resolveFixtureTimestamp(clock: string) {
const [hours = 0, minutes = 0, seconds = 0] = clock.split(":").map(Number);
const value = new Date();
value.setHours(hours, minutes, seconds, 0);
return value.getTime();
}
@@ -0,0 +1,517 @@
import ReactECharts from "echarts-for-react";
import {
Activity,
AlertTriangle,
Check,
ChevronRight,
Clock3,
Database,
FileSearch,
Minimize2,
ShieldCheck,
Sparkles,
Trash2,
X
} from "lucide-react";
import { useEffect, useState } from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
AnalysisMetric
} from "./workspace-model";
type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact;
onCollapse: () => void;
onDestroy: () => void;
};
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({
artifact,
onCollapse,
onDestroy
}: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter(
(block): block is EvidenceBlock => block.kind !== "metric-grid"
);
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
useEffect(() => {
setSelectedBlockId(null);
}, [artifact.id]);
useEffect(() => {
if (!selectedBlock) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSelectedBlockId(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedBlock]);
return (
<article
aria-label={`${artifact.title}分析成果`}
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
lang="zh-CN"
>
<ArtifactHeader artifact={artifact} />
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
<section aria-labelledby="artifact-summary-heading">
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
</div>
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
</div>
{metricBlock?.kind === "metric-grid" ? (
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
</div>
) : null}
</section>
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
</div>
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
</div>
<div className="grid grid-cols-12 gap-3.5">
{evidenceBlocks.map((block) => (
<AnalysisBlockView
key={block.id}
block={block}
selected={block.id === selectedBlockId}
onSelect={() => setSelectedBlockId(block.id)}
/>
))}
</div>
</section>
<ArtifactProvenance artifact={artifact} />
</div>
<ArtifactActionBar
artifact={artifact}
onCollapse={onCollapse}
onDestroy={onDestroy}
/>
{selectedBlock ? (
<ArtifactDetailDrawer
artifact={artifact}
block={selectedBlock}
onClose={() => setSelectedBlockId(null)}
/>
) : null}
</article>
);
}
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
return (
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
<div className="flex items-start justify-between gap-5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent Artifact
</span>
<span aria-hidden="true">·</span>
<span>{artifactTypeLabel(artifact.type)}</span>
<span aria-hidden="true">·</span>
<span>R{artifact.revision}</span>
</div>
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<LifecycleBadge lifecycle={artifact.lifecycle} />
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
<Clock3 size={13} aria-hidden="true" />
{artifact.generatedAt}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
</div>
</header>
);
}
function AnalysisBlockView({
block,
selected,
onSelect
}: {
block: EvidenceBlock;
selected: boolean;
onSelect: () => void;
}) {
const spanClass = block.span === 5
? "col-span-12 2xl:col-span-5"
: block.span === 7
? "col-span-12 2xl:col-span-7"
: "col-span-12";
return (
<section
aria-labelledby={`${block.id}-title`}
className={cn(
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
spanClass,
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
)}
>
<button
type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
aria-label={`查看${block.title}详情`}
onClick={onSelect}
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
</span>
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
</button>
<div className="border-t border-slate-100 p-4">
<AnalysisBlockBody block={block} />
</div>
</section>
);
}
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
if (block.kind === "chart") {
return (
<div className="h-[258px] min-h-0">
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
</div>
);
}
if (block.kind === "process-flow") {
return (
<ol className="space-y-0" aria-label={block.title}>
{block.nodes.map((node, index) => (
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
<span className={cn(
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
node.status === "complete" && "bg-emerald-100 text-emerald-700",
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
node.status === "pending" && "bg-slate-100 text-slate-500"
)}>
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 pt-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
</div>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div>
</li>
))}
</ol>
);
}
if (block.kind === "data-table") {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
}
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return (
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
<Database size={14} aria-hidden="true" />
数据来源
</h3>
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
{artifact.sources.map((source) => (
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
))}
</ul>
</div>
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
<AlertTriangle size={14} aria-hidden="true" />
分析限制
</h3>
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
{artifact.limitations.map((item) => (
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
))}
</ul>
</div>
</section>
);
}
function ArtifactDetailDrawer({
artifact,
block,
onClose
}: {
artifact: AnalysisArtifact;
block: EvidenceBlock;
onClose: () => void;
}) {
const blockFacts = getEvidenceBlockFacts(block);
return (
<aside
aria-label="证据详情"
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
>
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="uppercase tracking-[0.12em]">证据详情</span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
</div>
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
</div>
</div>
<button
type="button"
aria-label="关闭证据详情"
onClick={onClose}
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
>
<X size={15} aria-hidden="true" />
</button>
</div>
<div className="space-y-5 px-5 py-5">
<section aria-labelledby="evidence-purpose-heading">
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500">证据说明</p>
<p className="mt-2 text-sm leading-6 text-slate-700">
{evidenceBlockDescription(block)}
</p>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
<div className="flex items-center justify-between gap-3">
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500">数据概览</h4>
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
</div>
<dl className="mt-2 divide-y divide-slate-200">
{blockFacts.map((fact) => (
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
<dt className="text-slate-500">{fact.label}</dt>
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
</div>
))}
</dl>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500">分析上下文</h4>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
<dt className="text-slate-500">分析时间</dt>
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
<dt className="text-slate-500">分析范围</dt>
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500">综合置信度</dt>
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
</dl>
</section>
</div>
</aside>
);
}
function evidenceBlockDescription(block: EvidenceBlock) {
return "description" in block
? block.description
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
}
function evidenceBlockKindLabel(block: EvidenceBlock) {
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
if (block.kind === "process-flow") return "流程证据";
if (block.kind === "data-table") return "明细数据";
return "分析说明";
}
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
if (block.kind === "chart") {
return [
{ label: "数据序列", value: `${block.series.length} 组` },
{ label: "时间 / 类别点", value: `${block.categories.length} 个` },
{ label: "计量单位", value: block.unit }
];
}
if (block.kind === "data-table") {
return [
{ label: "证据记录", value: `${block.rows.length} 条` },
{ label: "数据字段", value: `${block.columns.length} 个` }
];
}
if (block.kind === "process-flow") {
return [
{ label: "流程步骤", value: `${block.nodes.length} 项` },
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length} 项` },
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length} 项` }
];
}
return [{ label: "分析段落", value: `${block.paragraphs.length} 段` }];
}
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
const formatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `${formatter.format(new Date(range.start))} 至 ${formatter.format(new Date(range.end))}`;
}
function ArtifactActionBar({
artifact,
onCollapse,
onDestroy
}: {
artifact: AnalysisArtifact;
onCollapse: () => void;
onDestroy: () => void;
}) {
return (
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
<span />
<div className="flex shrink-0 items-center gap-1">
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
</div>
</footer>
);
}
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
return (
<button
type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
>
<ShieldCheck size={14} aria-hidden="true" />
{action.label}
</button>
);
}
function MetricTile({ metric }: { metric: AnalysisMetric }) {
return (
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
</div>
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
</div>
);
}
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
}
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
if (type === "diagnosis") return "异常诊断";
if (type === "simulation") return "方案模拟";
return "指标分析";
}
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
if (relation === "required") return "强关联";
if (relation === "optional") return "可选";
return "无空间依赖";
}
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return {
animationDuration: 260,
animationEasing: "cubicOut",
color: block.series.map((series) => series.color),
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
series: block.series.map((series) => ({
name: series.name,
type: series.type ?? block.chartType,
data: series.values,
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
symbol: "circle",
symbolSize: 6,
barMaxWidth: 24,
lineStyle: { width: 2.5 },
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
}))
};
}
function metricToneClass(tone: AnalysisMetric["tone"]) {
if (tone === "danger") return "bg-rose-500";
if (tone === "warning") return "bg-amber-500";
if (tone === "success") return "bg-emerald-500";
if (tone === "info") return "bg-blue-600";
return "bg-slate-400";
}
@@ -0,0 +1,59 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({
artifact,
surfaceMode,
onOpen,
onDestroy
}: {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
onOpen: () => void;
onDestroy: () => void;
}) {
return (
<aside
aria-label="Agent 成果预览"
className={cn(
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
>
<span className="material-tone-agent hidden h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700 sm:grid">
<FileChartColumnIncreasing size={21} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
<Sparkles size={12} aria-hidden="true" />
新分析成果 · R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
</div>
<button
type="button"
onClick={onOpen}
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
>
<ChevronUp size={14} aria-hidden="true" />
打开成果
</button>
<button
type="button"
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</aside>
);
}
@@ -0,0 +1,14 @@
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
@@ -0,0 +1,67 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import {
collapseActiveArtifact,
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
resolveWorkbenchSurfaceMode,
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
type ArtifactRequestedView
} from "./workspace-model";
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
const [state, setState] = useState(createInitialArtifactWorkspaceState);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
const createTestArtifactPreview = useCallback(() => {
const artifact = createAnalysisArtifactFixture(
fixtureIndexRef.current,
fixtureInstanceRef.current
);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const openPreview = useCallback(() => {
setState(openPendingArtifact);
}, []);
const collapseArtifact = useCallback(() => {
setState(collapseActiveArtifact);
}, []);
const destroyPreview = useCallback(() => {
setState(destroyPendingArtifact);
}, []);
const destroyArtifact = useCallback(() => {
setState(destroyActiveArtifact);
}, []);
const setView = useCallback((view: ArtifactRequestedView) => {
setState((current) => requestArtifactView(current, view));
}, []);
const surfaceMode = useMemo(
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
[businessWorkspaceWidth, state]
);
return {
...state,
surfaceMode,
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
openPreview,
collapseArtifact,
destroyPreview,
destroyArtifact,
setView
};
}
@@ -0,0 +1,222 @@
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar";
import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model";
type WorkbenchMainFrameProps = {
mapContent: ReactNode;
mapOverlay?: ReactNode;
timelineContent: ReactNode;
timelineMode: "compact" | "expanded" | "summary";
timelineBottom: number;
activeArtifact: AnalysisArtifact | null;
pendingArtifact: AnalysisArtifact | null;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onOpenPendingArtifact: () => void;
onDestroyPendingArtifact: () => void;
onSetArtifactView: (view: ArtifactRequestedView) => void;
onCollapseArtifact: () => void;
onDestroyArtifact: () => void;
};
export function WorkbenchMainFrame({
mapContent,
mapOverlay,
timelineContent,
timelineMode,
timelineBottom,
activeArtifact,
pendingArtifact,
surfaceMode,
mapSplitAvailable,
onOpenPendingArtifact,
onDestroyPendingArtifact,
onSetArtifactView,
onCollapseArtifact,
onDestroyArtifact
}: WorkbenchMainFrameProps) {
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
const viewControlTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
const artifactTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
data-testid="workbench-main-frame"
data-timeline-mode={timelineMode}
>
<div
id="main-workspace"
data-layout-mode={surfaceMode}
className={cn(
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
surfaceMode !== "map_split" && "grid-cols-1"
)}
>
<section
id="workspace-map"
aria-label="供水管网地图工作区"
aria-hidden={!showMap}
className={cn(
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
!showMap && "hidden"
)}
>
{mapContent}
{mapOverlay}
</section>
<AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? (
<motion.section
key={activeArtifact.id}
id="artifact-workspace"
aria-label={`${activeArtifact.title}工作区`}
aria-hidden={!showArtifact}
className={cn(
"z-30 min-h-0 min-w-0 overflow-hidden",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
animate={showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}}
exit={prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.992 }}
transition={artifactTransition}
>
<AnalysisArtifactWorkspace
artifact={activeArtifact}
onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact}
/>
</motion.section>
) : null}
</AnimatePresence>
{pendingArtifact ? (
<ArtifactPeekBar
artifact={pendingArtifact}
surfaceMode={surfaceMode}
onOpen={onOpenPendingArtifact}
onDestroy={onDestroyPendingArtifact}
/>
) : null}
<AnimatePresence initial={false}>
{showArtifactViewControl ? (
<motion.div
className={cn(
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)}
initial={prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
>
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={surfaceMode}
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition}
>
{mapOnly ? (
<ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<motion.div
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
initial={false}
animate={{
bottom: timelineBottom,
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
right: 12
}}
transition={timelineTransition}
>
<AnimatePresence initial={false}>
{timelineContent ? (
<motion.div
key="operational-timeline"
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
transition={timelineTransition}
>
{timelineContent}
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
</div>
);
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace-context";
describe("workspace context", () => {
it("keeps operational time independent from artifact analytical time", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const withArtifact = reduceWorkspaceContext(createInitialWorkspaceContext(), {
type: "sync-artifact",
artifact
});
const selectedEvent = reduceWorkspaceContext(withArtifact, {
type: "select-operational-event",
eventId: "event-1",
cursorTime: "2026-08-19T10:30:00.000Z"
});
expect(selectedEvent.analytical.timeRange).toEqual(artifact.analyticalTimeRange);
expect(selectedEvent.operational.cursorTime).toBe("2026-08-19T10:30:00.000Z");
});
it("updates analytical context without moving the operational cursor", () => {
const initial = createInitialWorkspaceContext(new Date("2026-08-19T08:00:00.000Z"));
const cursorTime = initial.operational.cursorTime;
const withArtifact = reduceWorkspaceContext(initial, {
type: "sync-artifact",
artifact: createAnalysisArtifactFixture(1, 1)
});
expect(withArtifact.operational.cursorTime).toBe(cursorTime);
expect(withArtifact.analytical.artifactId).toBeTruthy();
});
it("clears selection while retaining cursor until returning to now", () => {
const initial = createInitialWorkspaceContext();
const selected = reduceWorkspaceContext(initial, {
type: "select-operational-event",
eventId: "event-2",
cursorTime: "2026-08-19T13:10:00.000Z"
});
const cleared = reduceWorkspaceContext(selected, { type: "clear-operational-event" });
expect(cleared.operational.selectedEventId).toBeNull();
expect(cleared.operational.cursorTime).toBe("2026-08-19T13:10:00.000Z");
});
});

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