5 Commits
Author SHA1 Message Date
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
103 changed files with 7180 additions and 2111 deletions
+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
+2 -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 图表。
## 主要功能
@@ -87,4 +87,4 @@ src/
## 构建说明
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+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>
+2 -5
View File
@@ -18,9 +18,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",
@@ -72,14 +70,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",
+3
View File
@@ -10,6 +10,9 @@ export default defineConfig({
reporter: "html",
use: {
baseURL,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
deviceScaleFactor: 1,
trace: "on-first-retry"
},
webServer: {
+9 -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)
@@ -189,9 +183,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 +445,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 +654,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 +734,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 +762,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 +789,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 +1617,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 +1752,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 +1984,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 +1998,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 +2042,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 +2093,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 +2350,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 +2532,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 +2540,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 +2828,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==}
@@ -3104,9 +2960,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 +3208,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 +3649,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 +3743,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 +3956,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 +4323,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 +4464,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 +4536,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 +4565,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 +4597,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 +5237,7 @@ snapshots:
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
optional: true
'@swc/types@0.1.27':
dependencies:
@@ -5656,10 +5352,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 +5513,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 +5691,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 +5703,6 @@ snapshots:
aria-query@5.3.2: {}
array-back@6.2.3: {}
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
@@ -6065,10 +5739,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 +5790,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 +6058,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 +6269,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 +6279,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 +6623,6 @@ snapshots:
jsesc@3.1.0: {}
json-bignum@0.0.3: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -7069,8 +6717,6 @@ snapshots:
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.merge@4.6.2: {}
longest-streak@3.1.0: {}
@@ -7586,8 +7232,6 @@ snapshots:
minimist@1.2.8: {}
mjolnir.js@3.0.1: {}
motion-dom@12.42.2:
dependencies:
motion-utils: 12.39.0
@@ -8091,11 +7735,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 +7825,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 +8006,6 @@ snapshots:
word-wrap@1.2.5: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
+4 -4
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 integrated composer styling through Tailwind v4", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
@@ -116,6 +116,6 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
);
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
expect(commandShadow).not.toContain("0px 1px 3px 0px");
expect(commandShadow).toBe("none");
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
});
@@ -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 acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
)}
>
<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">
@@ -15,10 +15,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 +48,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 +71,7 @@ import {
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
@@ -77,7 +80,10 @@ import type {
AgentUiResult
} from "../types";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
@@ -85,6 +91,7 @@ type AgentCommandPanelProps = {
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
@@ -127,6 +134,7 @@ const AGENT_CAPABILITIES = [
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
personaState,
sessionTitle = "新对话",
@@ -134,6 +142,7 @@ export function AgentCommandPanel({
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
messages = [],
streamRenderState = {},
@@ -159,6 +168,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 +209,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 +268,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"
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
: 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 +333,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 +363,122 @@ 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>
{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 +507,7 @@ export function AgentCommandPanel({
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
</AnimatePresence>
@@ -384,14 +518,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 +563,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 +575,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 +587,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}
@@ -497,22 +649,22 @@ 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",
"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 text-blue-600 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:brightness-95"
)}
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={
@@ -539,7 +691,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 +750,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 +792,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 +819,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 +860,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 +883,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 +929,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 +985,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 +1057,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 +1178,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 +1367,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 +1464,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)}
@@ -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>
)}
@@ -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">
@@ -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}
>
@@ -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}
+3
View File
@@ -1,5 +1,7 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
@@ -55,6 +57,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;
}
+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 },
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getStatusActivity,
getStatusTone,
getWorkOrderStageActivity,
getWorkOrderStageTone
} from "./scheduled-condition-feed-utils";
describe("scheduled condition status presentation", () => {
it("maps lifecycle states to the shared semantic language", () => {
expect({
pending: getStatusTone("pending"),
running: getStatusTone("running"),
awaitingReply: getStatusTone("awaiting_reply"),
completed: getStatusTone("completed"),
warning: getStatusTone("warning"),
error: getStatusTone("error")
}).toEqual({
pending: "neutral",
running: "info",
awaitingReply: "warning",
completed: "success",
warning: "warning",
error: "danger"
});
expect(getStatusActivity("running")).toBe("loading");
expect(getStatusActivity("awaiting_reply")).toBe("static");
});
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
expect({
dispatch: getWorkOrderStageTone("dispatch"),
execution: getWorkOrderStageTone("execution"),
reply: getWorkOrderStageTone("reply")
}).toEqual({
dispatch: "neutral",
execution: "info",
reply: "warning"
});
expect(getWorkOrderStageActivity("execution")).toBe("loading");
});
});
@@ -5,51 +5,75 @@ import {
Loader2,
XCircle
} from "lucide-react";
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
import type {
ScheduledConditionItem,
ScheduledConditionStatus,
ScheduledConditionItemStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
export const statusLabels: Record<ScheduledConditionStatus, string> = {
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
warning: "关注",
error: "异常",
pending: "预约"
};
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
completed: "border-green-100 bg-green-50 text-green-700",
running: "border-blue-100 bg-blue-50 text-blue-700",
warning: "border-orange-100 bg-orange-50 text-orange-700",
error: "border-red-100 bg-red-50 text-red-700",
pending: "border-slate-200 bg-slate-100 text-slate-600"
};
const statusTones = {
completed: "success",
running: "info",
awaiting_reply: "warning",
warning: "warning",
error: "danger",
pending: "neutral"
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
const statusActivities = {
completed: "static",
running: "loading",
awaiting_reply: "static",
warning: "static",
error: "static",
pending: "static"
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
awaiting_reply: Clock3,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
};
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
completed: "bg-green-50 text-green-700",
running: "bg-blue-50 text-blue-700",
warning: "bg-orange-50 text-orange-700",
error: "bg-red-50 text-red-700",
pending: "bg-slate-100 text-slate-600"
};
const workOrderStageTones = {
dispatch: "neutral",
execution: "info",
reply: "warning"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
const workOrderStageActivities = {
dispatch: "static",
execution: "loading",
reply: "static"
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"running",
"awaiting_reply",
"warning",
"error",
"completed",
"pending"
];
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
export type ConditionTaskFilterOption = {
value: ConditionTaskFilterValue;
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
};
export type ConditionStatusFilterOption = {
value: ScheduledConditionStatus;
value: ScheduledConditionItemStatus;
label: string;
count: number;
};
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
}
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
const statusCounts = new Map<ScheduledConditionStatus, number>();
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
conditions.forEach((condition) => {
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
if (status === "running") {
return "执行中";
}
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
);
}
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
if (stageId === "execution") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
if (stageId === "reply") {
return "border-green-100 bg-green-50 text-green-700";
}
return "border-indigo-100 bg-indigo-50 text-indigo-700";
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageTones[stageId] : "neutral";
}
export function getStatusIcon(status: ScheduledConditionStatus) {
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
return stageId ? workOrderStageActivities[stageId] : "static";
}
export function getStatusIcon(status: ScheduledConditionItemStatus) {
return statusIcons[status];
}
export function getStatusIconClassName(status: ScheduledConditionStatus) {
return statusIconClassNames[status];
export function getStatusTone(status: ScheduledConditionItemStatus) {
return statusTones[status];
}
export function getStatusActivity(status: ScheduledConditionItemStatus) {
return statusActivities[status];
}
@@ -4,6 +4,7 @@ import {
ChevronRight,
ClipboardList,
History,
X,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
@@ -20,6 +21,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
@@ -27,8 +29,12 @@ import {
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusActivity,
getStatusIcon,
getStatusIconClassName,
getStatusTone,
getWorkOrderCurrentStage,
getWorkOrderStageActivity,
getWorkOrderStageTone,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
@@ -36,10 +42,18 @@ import {
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
"surface-reading rounded-xl border border-slate-200/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
"surface-control rounded-xl border border-slate-200/70";
export type ScheduledConditionFeedPresentation =
| "desktop-dock"
| "desktop-floating"
| "mobile-sheet";
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
@@ -50,6 +64,7 @@ type ScheduledConditionFeedProps = {
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
@@ -60,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
};
export function ScheduledConditionFeed({
presentation = "desktop-dock",
conditions = [],
expanded: controlledExpanded,
focusRequest,
@@ -70,13 +86,19 @@ 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 desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
@@ -89,10 +111,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 +129,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 +201,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 +213,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 +268,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 +283,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
? "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 +338,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"
mobileSheet
? "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",
mobileSheet
? 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 +383,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",
mobileSheet && (expanded ? "hidden" : "h-full w-full")
)}
>
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
@@ -305,7 +399,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 +419,7 @@ export function ScheduledConditionFeed({
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
@@ -344,6 +439,7 @@ type ConditionTimelinePanelProps = {
function ConditionTimelinePanel({
expanded,
presentation,
loading,
recentConditions,
futureWorkOrders,
@@ -365,9 +461,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 +486,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 +503,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 +520,11 @@ function ConditionTimelinePanel({
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
}
/>
)}
</div>
@@ -446,7 +560,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 +591,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 +633,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 +655,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 +704,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 +730,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 +748,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 +765,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 +788,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 +839,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 +862,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,11 +1,7 @@
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 { useEffect, type ComponentProps } from "react";
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -14,52 +10,53 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
mobileOpen: boolean;
mobilePanelCollapsing: boolean;
conditionExpanded: 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,
conditionExpanded,
personaState,
statusLabel,
viewportWidth,
onCollapsePanel,
onCloseMobilePanel,
onExpandPanel,
onOpenMobilePanel
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth);
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const committedWidth = resize.committedWidth ?? defaultWidth;
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-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
style={{ width: resize.width ?? defaultWidth }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
presentation="desktop-floating"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
className={cn(
@@ -83,36 +80,5 @@ export function WorkbenchAgentPanels({
/>
)}
</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>
@@ -274,7 +298,11 @@ export function UserMenu({
<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}>
@@ -316,15 +344,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 +358,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 +448,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 +490,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";
@@ -9,9 +9,7 @@ import {
FlaskConical,
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";
@@ -77,7 +75,8 @@ export function WorkbenchTopBar({
onExportConfig
}: 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 +84,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 +138,24 @@ 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">
{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
@@ -191,20 +202,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 +215,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 +234,22 @@ function ConditionFeedToggle({
);
}
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
function HeaderReadout({
icon: Icon,
label,
value
}: {
icon?: LucideIcon;
label: string;
value: string;
}) {
return (
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<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,
@@ -13,10 +13,12 @@ import {
createAgentApiClient,
fetchAgentBootstrap,
fetchAgentSessions,
hasPendingAgentInput,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentConnectionStatus,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
@@ -45,8 +47,6 @@ 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>;
@@ -71,7 +71,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 +141,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;
@@ -226,7 +228,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 +239,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
},
onFinish: () => {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setConnectionStatus("online");
setStatusLabel("Agent 分析完成");
}
});
@@ -303,7 +306,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 +316,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
return;
}
setRuntimeAvailability("unavailable");
setConnectionStatus("offline");
setStatusLabel("Agent 后端不可用");
}, [backendConnectionError]);
@@ -371,10 +374,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 +480,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 +591,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
clearSessionId();
setSessionTitle("新对话");
setStatusLabel(nextStatusLabel);
setRuntimeAvailability("connected");
setConnectionStatus("online");
setPermissionOverrides({});
setQuestionOverrides({});
setStreamRenderState({});
@@ -793,6 +795,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
modelOptions,
openMobilePanel,
closeMobilePanel,
connectionStatus,
panelCollapsing,
panelOpen,
personaState,
@@ -824,23 +827,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 +845,7 @@ function deriveAgentPersonaState(
chatStatus === "submitted" ||
chatStatus === "streaming" ||
hasRunningSessionWork(messages) ||
runtimeAvailability === "connecting"
connectionStatus === "connecting"
) {
return "thinking";
}
@@ -856,14 +853,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"
@@ -199,7 +185,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);
}
@@ -248,7 +239,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 +254,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 +309,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" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
+5
View File
@@ -6,6 +6,11 @@ export type {
WorkbenchMapControllerState,
WorkbenchMapErrorCode
} from "./map/workbench-map-controller";
export type {
PipeFlowCalculation,
PipeFlowDirection,
PipeFlowSummary
} from "./map/flow-overlay";
export type {
LayerGroupId,
LayerGroupStylePatch,
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { getWorkbenchBasemapTone } from "./workbench-layout";
import {
clampAgentPanelWidth,
getAgentPanelDefaultWidth,
getAgentPanelMaxWidth,
getWorkbenchBasemapTone,
getWorkbenchCameraPadding,
getWorkbenchViewportLayout
} from "./workbench-layout";
describe("workbench basemap surface tone", () => {
it.each([
@@ -11,3 +18,68 @@ describe("workbench basemap surface tone", () => {
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
});
});
describe("workbench floating panels", () => {
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("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);
});
});
@@ -4,32 +4,80 @@ export const WORKBENCH_LAYOUT = {
persistentConditionMinWidth: 1280,
wideMinWidth: 1536,
collapsedAgentWidth: 72,
maxAgentViewportRatio: 0.5,
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
}
} as const;
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
export function clampAgentPanelWidth(
width: number,
viewportWidth: number,
conditionExpanded = false
) {
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
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 +85,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
export type WorkbenchViewportLayout = {
agentWidth: number;
conditionWidth: number;
conditionExpandedWidth: number;
toolbarWidth: number;
tickerWidth: number;
};
@@ -65,8 +114,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 +146,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`
+164 -24
View File
@@ -8,8 +8,10 @@ import {
type CSSProperties
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarClock } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
@@ -29,6 +31,10 @@ import {
import { env } from "@/shared/config/env";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel";
import {
MobileWorkbenchSheet,
type MobileWorkbenchSheetSnap
} 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";
@@ -92,10 +98,17 @@ export function MapWorkbenchPage() {
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
@@ -133,6 +146,9 @@ export function MapWorkbenchPage() {
const handleChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleChange();
@@ -167,20 +183,75 @@ export function MapWorkbenchPage() {
});
function openAgentPanelForViewport() {
if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < 1440) {
setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false);
}
agent.expandPanel();
return;
}
agent.openMobilePanel();
setMobileSheet("agent");
setMobileSheetSnap("half");
}
const closeMobileSheet = useCallback(() => {
setMobileSheet(null);
}, []);
function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
setAgentCollapsedForCondition(agent.panelOpen);
agent.collapsePanel();
} else if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
agent.expandPanel();
}
setConditionFeedExpanded(nextExpanded);
}
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
return;
}
setConditionFeedExpanded(false);
if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
agent.expandPanel();
}
}, [
agent,
agentCollapsedForCondition,
conditionFeedExpanded,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
setActiveToolId(null);
if (isLargeScreen) {
setConditionFeedVisible((current) => !current);
return;
}
if (mobileSheet === "condition") {
closeMobileSheet();
return;
}
setConditionFeedExpanded(false);
setMobileSheetSnap("half");
setMobileSheet("condition");
}
const leftPanelOpen = isLargeScreen && agent.panelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
@@ -189,7 +260,9 @@ export function MapWorkbenchPage() {
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth
});
const activeAgentUiResults = useMemo(
@@ -223,10 +296,9 @@ export function MapWorkbenchPage() {
mode: activeMeasureModeId,
unit: activeMeasureUnitId
});
const prefersReducedMotion = useReducedMotion();
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
@@ -489,6 +561,10 @@ export function MapWorkbenchPage() {
setConditionFeedVisible(true);
setActiveToolId(null);
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
@@ -608,7 +684,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,
leftPanelOpen,
rightPanelOpen,
rightPanelExpanded,
agentPanelWidth
),
duration: trustedAction.durationMs ?? 500
});
showMapNotice({
@@ -732,6 +814,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,
@@ -757,7 +840,14 @@ 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": `${
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
}px`
} as CSSProperties
}
>
<div
ref={mapContainerRef}
@@ -773,17 +863,14 @@ export function MapWorkbenchPage() {
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={WORKBENCH_USER}
conditionFeedVisible={shouldShowConditionFeed}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={() => {
setConditionFeedVisible((current) => !current);
setActiveToolId(null);
}}
onToggleConditionFeed={toggleConditionFeedForViewport}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
@@ -800,19 +887,17 @@ export function MapWorkbenchPage() {
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
mobileOpen={agent.mobileOpen}
mobilePanelCollapsing={agent.mobilePanelCollapsing}
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
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">
<div className="absolute right-2 top-24 z-20 hidden lg:block">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
) : null}
@@ -820,13 +905,14 @@ export function MapWorkbenchPage() {
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 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}
@@ -851,8 +937,8 @@ export function MapWorkbenchPage() {
/>
) : null}
{!agent.mobileOpen ? (
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileSheet === null ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
<ToolbarPanel
{...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -890,16 +976,70 @@ export function MapWorkbenchPage() {
) : null}
</AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
{!agent.mobileOpen ? (
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
{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>
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="w-full justify-center"
className="h-11 justify-center"
/>
<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 工作台抽屉" : "工况任务抽屉"}
snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet}
>
{mobileSheet === "agent" ? (
<AgentCommandPanel
{...agentPanelProps}
presentation="mobile-sheet"
onCollapse={closeMobileSheet}
/>
) : (
<ScheduledConditionFeed
presentation="mobile-sheet"
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
)}
</MobileWorkbenchSheet>
) : null}
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
{!mapReady && !mapError ? (
@@ -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: 96 }
},
{
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: 96 }
},
{
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 };
}
+406 -68
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);
}
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
map.addLayer({
id: FLOW_REVERSE_LINE_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
layout: flowLineLayout(),
paint: {
"line-pattern": FLOW_REVERSE_IMAGE_ID,
"line-width": 4,
"line-opacity": directionExpression(-1, 0.92)
}
}, WORKBENCH_INTERACTION_BEFORE_ID);
}
}
function isFlowFeature(feature: Feature): feature is FlowFeature {
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
function flowLineLayout() {
return {
visibility: "visible" as const,
"line-cap": "round" as const,
"line-join": "round" as const
};
}
function setFlowLayerVisibility(
map: MapLibreMap,
visibility: "visible" | "none"
) {
[
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID
].forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
function setFlowMockVisibility(
map: MapLibreMap,
visible: boolean
) {
const opacityByLayer = [
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
] as const;
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
if (map.getLayer(layerId)) {
map.setPaintProperty(
layerId,
property,
directionExpression(direction, opacity, visible)
);
}
});
}
@@ -1,34 +0,0 @@
import { LayerExtension, type Layer } from "@deck.gl/core";
const flowPathUniforms = {
name: "flowPath",
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
uniformTypes: { phase: "f32" }
} as const;
export class FlowPathExtension extends LayerExtension {
static extensionName = "FlowPathExtension";
static defaultProps = { flowPhase: { type: "number", value: 0 } };
getShaders() {
return {
modules: [flowPathUniforms],
inject: {
"fs:DECKGL_FILTER_COLOR": `
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
color.a *= 0.22 + flowPulse * 0.78;
`
}
};
}
draw(this: Layer) {
const state = this.state as unknown as {
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
};
state.model?.shaderInputs?.setProps({
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
});
}
}
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
iconMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
setLayout(
map,
`${groupId}-symbol`,
"icon-size",
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
if ("iconSize" in visual) {
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
}
}
function scale(value: unknown, multiplier: number): unknown {
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
map.setLayoutProperty(layerId, property, (value ?? null) as never);
}
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
+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 };
}
+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: "预约"
+20
View File
@@ -0,0 +1,20 @@
import { cva } from "class-variance-authority"
export const BUTTON_INTERACTION_CLASS_NAME =
"action-control relative isolate touch-manipulation select-none transition-[color,background-color,border-color,box-shadow,filter] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] focus-visible:z-10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
export const buttonInteractionVariants = cva(BUTTON_INTERACTION_CLASS_NAME, {
variants: {
tone: {
neutral:
"text-slate-700 [@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)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)] data-[state=open]:border-[var(--action-border-hover)] data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)]",
brand:
"border-[var(--action-blue)] bg-[var(--action-blue)] text-white [@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-blue-pressed)] [&:active:not(:disabled)]:bg-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]",
danger:
"text-[var(--status-danger-foreground)] [@media(hover:hover)]:hover:border-[var(--status-danger-border)] [@media(hover:hover)]:hover:bg-[var(--status-danger-soft)] [&:active:not(:disabled)]:border-[var(--status-danger-border)] [&:active:not(:disabled)]:bg-[var(--status-danger-soft)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]"
}
},
defaultVariants: {
tone: "neutral"
}
})
+83
View File
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { Button } from "@/shared/ui/button"
describe("Button", () => {
it("uses the shared primary and interaction states by default", () => {
render(<Button>执行调度</Button>)
const button = screen.getByRole("button", { name: "执行调度" })
expect(button).toHaveAttribute("data-slot", "button")
expect(button.className).toContain("bg-[var(--action-blue)]")
expect(button.className).toContain("focus-visible:ring-[var(--action-focus-ring)]")
expect(button.className).toContain("[&:active:not(:disabled)]:bg-[var(--action-blue-pressed)]")
expect(button.className).toContain(
"[&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]"
)
expect(button.className).not.toContain("translate-y")
})
it("keeps compact controls visually small with an expanded hit target", () => {
render(
<Button aria-label="刷新" size="icon-sm" variant="ghost">
R
</Button>
)
const button = screen.getByRole("button", { name: "刷新" })
expect(button.className).toContain("h-8")
expect(button.className).toContain("w-8")
expect(button.className).toContain("after:-inset-1")
})
it("exposes a stable and inaccessible-to-repeat loading state", async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(
<Button loading loadingLabel="正在提交调度" onClick={onClick}>
提交调度
</Button>
)
const button = screen.getByRole("button", { name: /提交调度/ })
expect(button).toBeDisabled()
expect(button).toHaveAttribute("aria-busy", "true")
expect(button).toHaveAttribute("data-loading", "true")
expect(screen.getByText("正在提交调度")).toHaveClass("sr-only")
await user.click(button)
expect(onClick).not.toHaveBeenCalled()
})
it("preserves asChild composition", () => {
render(
<Button asChild variant="link">
<a href="/sessions">查看会话</a>
</Button>
)
const link = screen.getByRole("link", { name: "查看会话" })
expect(link).toHaveAttribute("href", "/sessions")
expect(link).toHaveAttribute("data-slot", "button")
})
it("blocks and labels loading asChild links", async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(
<Button asChild loading loadingLabel="正在打开会话" onClick={onClick}>
<a href="/sessions">查看会话</a>
</Button>
)
const link = screen.getByRole("link", { name: "正在打开会话" })
expect(link).toHaveAttribute("aria-busy", "true")
expect(link).toHaveAttribute("aria-disabled", "true")
expect(link).toHaveAttribute("tabindex", "-1")
await user.click(link)
expect(onClick).not.toHaveBeenCalled()
})
})
+80 -17
View File
@@ -2,36 +2,44 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { BUTTON_INTERACTION_CLASS_NAME } from "@/shared/ui/button-interaction"
import { cn } from "@/shared/ui/cn"
import { Spinner } from "@/shared/ui/spinner"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
cn(
BUTTON_INTERACTION_CLASS_NAME,
"inline-flex items-center justify-center gap-2 whitespace-nowrap border border-transparent text-sm font-semibold [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)] [@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [&:active:not(:disabled)]:border-[var(--action-blue-pressed)] [&:active:not(:disabled)]:bg-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-primary-pressed)]",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
"border-[var(--action-danger)] bg-[var(--action-danger)] text-white shadow-[var(--action-shadow-secondary)] [@media(hover:hover)]:hover:border-[var(--action-danger-hover)] [@media(hover:hover)]:hover:bg-[var(--action-danger-hover)] [&:active:not(:disabled)]:border-[var(--action-danger-pressed)] [&:active:not(:disabled)]:bg-[var(--action-danger-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
"border-[var(--action-border)] bg-[var(--surface-reading)] text-slate-700 shadow-[var(--action-shadow-secondary)] [@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)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
"border-[var(--action-border)] bg-[var(--surface-control)] text-slate-700 shadow-[var(--action-shadow-secondary)] [@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)] [&:active:not(:disabled)]:border-[var(--action-border-pressed)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-[var(--action-shadow-secondary-pressed)]",
ghost:
"bg-transparent text-slate-600 shadow-none [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [&:active:not(:disabled)]:bg-[var(--action-soft-pressed)] [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-none",
link:
"h-auto border-0 bg-transparent p-0 text-[var(--action-blue)] shadow-none underline-offset-4 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:underline [&:active:not(:disabled)]:text-[var(--action-blue-pressed)] [&:active:not(:disabled)]:shadow-none"
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
"icon-sm": "h-8 w-8",
},
default: "h-10 rounded-xl px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs after:absolute after:-inset-1 after:content-['']",
lg: "h-11 rounded-xl px-6",
icon: "h-10 w-10 rounded-xl",
"icon-sm":
"h-8 w-8 rounded-lg after:absolute after:-inset-1 after:content-['']"
}
},
defaultVariants: {
variant: "default",
size: "default",
},
size: "default"
}
}
)
@@ -39,17 +47,72 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
loadingLabel?: string
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
children,
className,
variant,
size,
asChild = false,
disabled,
loading = false,
loadingLabel = "处理中",
onClick,
tabIndex,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const blocked = disabled || loading
const childContent =
asChild && React.isValidElement<{ children?: React.ReactNode }>(children)
? children.props.children
: children
const loadingContent = loading ? (
<>
<span aria-hidden="true" className="invisible inline-flex items-center gap-2">
{childContent}
</span>
<Spinner
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
/>
<span className="sr-only">{loadingLabel}</span>
</>
) : null
const renderedChildren =
loading && asChild && React.isValidElement(children)
? React.cloneElement(children, undefined, loadingContent)
: loadingContent ?? children
return (
<Comp
aria-busy={loading || undefined}
aria-disabled={asChild && blocked ? true : undefined}
className={cn(buttonVariants({ variant, size, className }))}
data-loading={loading || undefined}
data-slot="button"
disabled={asChild ? undefined : blocked}
onClick={(event) => {
if (asChild && blocked) {
event.preventDefault()
event.stopPropagation()
return
}
onClick?.(event)
}}
ref={ref}
tabIndex={asChild && blocked ? -1 : tabIndex}
{...props}
/>
>
{renderedChildren}
</Comp>
)
}
)
+4 -4
View File
@@ -82,11 +82,11 @@ const inputGroupButtonVariants = cva(
{
variants: {
size: {
xs: "h-6 gap-1 rounded-sm px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
xs: "h-8 gap-1 rounded-lg px-2 after:absolute after:-inset-1 after:content-[''] has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-lg px-2.5 after:absolute after:-inset-1 after:content-[''] has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-sm p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
"size-8 rounded-lg p-0 after:absolute after:-inset-1 after:content-[''] has-[>svg]:p-0",
"icon-sm": "size-8 p-0 after:absolute after:-inset-1 after:content-[''] has-[>svg]:p-0",
},
},
defaultVariants: {
+54
View File
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { Loader2 } from "lucide-react";
import { describe, expect, it } from "vitest";
import { StatusBadge, StatusDot, StatusIcon } from "./status";
describe("semantic status primitives", () => {
it.each(["neutral", "info", "success", "warning", "danger"] as const)(
"renders the %s tone through the shared semantic class",
(tone) => {
render(<StatusBadge tone={tone}>{tone}</StatusBadge>);
expect(screen.getByText(tone)).toHaveClass(`status-tone-${tone}`);
expect(screen.getByText(tone)).toHaveAttribute("data-status-tone", tone);
}
);
it("keeps a standalone status dot accessible when it has a label", () => {
render(<StatusDot tone="danger" label="数据源离线" />);
expect(screen.getByRole("status", { name: "数据源离线" })).toHaveAttribute(
"data-status-tone",
"danger"
);
});
it("marks decorative dots as hidden from assistive technology", () => {
const { container } = render(<StatusDot tone="success" />);
expect(container.firstChild).toHaveAttribute("aria-hidden", "true");
});
it("exposes activity modes for reduced-motion-safe CSS", () => {
const { container } = render(
<StatusIcon tone="info" activity="loading">
<Loader2 />
</StatusIcon>
);
expect(container.querySelector(".status-glyph")).toHaveAttribute(
"data-activity",
"loading"
);
});
it("does not add a breathing marker when an active badge has no explicit icon", () => {
const { container } = render(
<StatusBadge tone="info" activity="loading">
执行中
</StatusBadge>
);
expect(container.querySelector(".status-dot")).not.toBeInTheDocument();
});
});
+126
View File
@@ -0,0 +1,126 @@
import type { ComponentProps, ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
export type StatusTone = "neutral" | "info" | "success" | "warning" | "danger";
export type StatusActivity = "static" | "live" | "loading";
const toneClassNames: Record<StatusTone, string> = {
neutral: "status-tone-neutral",
info: "status-tone-info",
success: "status-tone-success",
warning: "status-tone-warning",
danger: "status-tone-danger"
};
type StatusBadgeProps = ComponentProps<"span"> & {
tone: StatusTone;
activity?: StatusActivity;
icon?: ReactNode;
size?: "sm" | "md";
};
export function StatusBadge({
tone,
activity = "static",
icon,
size = "sm",
className,
children,
...props
}: StatusBadgeProps) {
return (
<span
className={cn(
"status-badge inline-flex shrink-0 items-center rounded-full border font-semibold",
size === "sm" ? "min-h-5 gap-1 px-2 py-0.5 text-xs leading-4" : "min-h-6 gap-1.5 px-2.5 py-0.5 text-xs leading-5",
toneClassNames[tone],
className
)}
data-status-tone={tone}
{...props}
>
{icon ? (
<span
className="status-glyph grid shrink-0 place-items-center"
data-activity={activity}
aria-hidden="true"
>
{icon}
</span>
) : null}
{children}
</span>
);
}
type StatusDotProps = Omit<ComponentProps<"span">, "children"> & {
tone: StatusTone;
activity?: StatusActivity;
label?: string;
size?: "sm" | "md";
};
export function StatusDot({
tone,
activity = "static",
label,
size = "sm",
className,
...props
}: StatusDotProps) {
return (
<span
className={cn(
"status-dot inline-block shrink-0 rounded-full",
size === "sm" ? "h-1.5 w-1.5" : "h-2 w-2",
toneClassNames[tone],
className
)}
data-activity={activity}
data-status-tone={tone}
role={label ? "status" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
{...props}
/>
);
}
type StatusIconProps = ComponentProps<"span"> & {
tone: StatusTone;
activity?: StatusActivity;
shape?: "rounded" | "circle";
size?: "xs" | "sm" | "md";
};
export function StatusIcon({
tone,
activity = "static",
shape = "rounded",
size = "sm",
className,
children,
...props
}: StatusIconProps) {
return (
<span
className={cn(
"status-icon inline-grid shrink-0 place-items-center border",
shape === "circle" ? "rounded-full" : "rounded-lg",
size === "xs" ? "h-3.5 w-3.5" : size === "sm" ? "h-6 w-6" : "h-7 w-7",
toneClassNames[tone],
className
)}
data-status-tone={tone}
{...props}
>
<span
className="status-glyph grid place-items-center"
data-activity={activity}
aria-hidden="true"
>
{children}
</span>
</span>
);
}
+625 -177
View File
@@ -1,3 +1,4 @@
@import "@fontsource-variable/noto-sans-sc";
@import "tailwindcss";
@import "tw-animate-css";
@source "../node_modules/streamdown/dist";
@@ -64,6 +65,44 @@
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
:where(*) {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
:where(*)::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
:where(*)::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 0;
}
:where(*)::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--scrollbar-radius);
}
:where(*):hover::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-hover);
}
:where(*)::-webkit-scrollbar-thumb:active {
background: var(--scrollbar-thumb-active);
}
:where(*)::-webkit-scrollbar-corner {
background: var(--scrollbar-track);
}
@media (forced-colors: active) {
:where(*) {
scrollbar-color: auto;
}
}
}
:root {
@@ -71,47 +110,96 @@
background: oklch(94.5% 0.012 250);
--canvas-map: oklch(94.5% 0.012 250);
--canvas-map-grid: rgba(37, 99, 235, 0.06);
--acrylic-navigation: rgba(242, 248, 252, 0.74);
--acrylic-panel: rgba(232, 241, 249, 0.58);
--acrylic-control: rgba(249, 251, 255, 0.84);
--acrylic-panel-blur: 22px;
--acrylic-navigation: rgba(234, 243, 250, 0.64);
--acrylic-panel: rgba(223, 235, 246, 0.56);
--acrylic-control: rgba(242, 248, 253, 0.74);
--agent-floating-reading-surface: rgba(235, 244, 251, 0.82);
--agent-floating-reading-item: rgba(249, 251, 254, 0.72);
--agent-floating-reading-item-hover: rgba(249, 251, 254, 0.9);
--agent-floating-reading-active: rgba(219, 234, 254, 0.82);
--agent-user-message-surface: rgba(224, 229, 249, 0.92);
--agent-user-message-border: rgba(79, 70, 229, 0.22);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.86);
--agent-operational-surface: rgba(255, 255, 255, 0.86);
--acrylic-panel-blur: 24px;
--acrylic-control-blur: 18px;
--acrylic-backdrop-saturation: 1.2;
--acrylic-exclusion: rgba(255, 255, 255, 0.05);
--acrylic-luminosity: rgba(255, 255, 255, 0.22);
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.06'/%3E%3C/svg%3E");
--acrylic-border: rgba(255, 255, 255, 0.58);
--acrylic-outline: rgba(71, 85, 105, 0.18);
--acrylic-highlight: rgba(255, 255, 255, 0.82);
--acrylic-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
--surface-dock: oklch(97% 0.006 250);
--surface-control: rgba(252, 253, 255, 0.96);
--surface-well: rgba(232, 240, 248, 0.2);
--surface-reading: rgba(255, 255, 255, 0.985);
--glass-transient: rgba(248, 252, 255, 0.88);
--acrylic-backdrop-saturation: 1.08;
--agent-floating-acrylic-backdrop: blur(var(--acrylic-control-blur))
saturate(var(--acrylic-backdrop-saturation));
--acrylic-exclusion: rgba(255, 255, 255, 0.035);
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
--scrollbar-size: 4px;
--scrollbar-radius: 2px;
--scrollbar-track: transparent;
--scrollbar-thumb: rgba(100, 116, 139, 0.22);
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.3);
--scrollbar-thumb-active: rgba(71, 85, 105, 0.36);
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.035'/%3E%3C/svg%3E");
--acrylic-border: rgba(255, 255, 255, 0.52);
--acrylic-outline: rgba(63, 83, 108, 0.18);
--acrylic-highlight: rgba(255, 255, 255, 0.68);
--shadow-navigation: 0 1px 0 rgba(48, 65, 85, 0.14);
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
--shadow-agent-history-bottom: 0 12px 12px -12px rgba(15, 33, 55, 0.32);
--shadow-control: 0 6px 18px rgba(15, 33, 55, 0.1);
--shadow-transient: 0 14px 34px rgba(15, 33, 55, 0.18);
--shadow-agent-floating:
0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
--shadow-agent-operational: 0 12px 32px rgba(15, 33, 55, 0.14), 0 3px 10px rgba(15, 33, 55, 0.08);
--surface-dock: oklch(96.5% 0.012 248);
--surface-control: rgba(239, 246, 252, 0.92);
--surface-well: rgba(213, 227, 239, 0.66);
--surface-reading: rgba(250, 252, 254, 0.96);
--glass-transient: rgba(232, 242, 250, 0.66);
--surface-border: oklch(86.5% 0.012 250);
--surface-divider: oklch(90.5% 0.009 250);
--surface-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
--dock-shadow: 8px 0 24px rgba(15, 23, 42, 0.07);
--radius-icon: 8px;
--radius-control: 12px;
--radius-panel: 16px;
--radius-pill: 9999px;
--action-blue: #2563eb;
--action-blue-hover: #1d4ed8;
--action-blue-soft: #dbeafe;
--action-blue-pressed: #1e40af;
--action-soft: oklch(96.8% 0.018 255.5);
--action-soft-pressed: oklch(94.8% 0.028 255.5);
--action-selection-soft: #e8f1ff;
--action-selection-soft-hover: #dbeafe;
--action-selection-soft-pressed: #cfe2ff;
--action-selection-border: rgba(59, 130, 246, 0.2);
--action-border: oklch(88.2% 0.015 250);
--action-border-hover: rgba(37, 99, 235, 0.3);
--action-border-pressed: rgba(37, 99, 235, 0.44);
--action-danger: #dc2626;
--action-danger-hover: #b91c1c;
--action-danger-pressed: #991b1b;
--action-focus-ring: rgba(37, 99, 235, 0.34);
--action-focus-offset: rgba(250, 252, 254, 0.96);
--action-shadow-primary: 0 5px 12px rgba(37, 99, 235, 0.2);
--action-shadow-secondary: 0 2px 5px rgba(15, 33, 55, 0.08);
--action-shadow-primary-pressed: 0 1px 3px rgba(30, 64, 175, 0.18);
--action-shadow-secondary-pressed: 0 1px 2px rgba(15, 33, 55, 0.06);
--ink: #0f172a;
--ink-secondary: #334155;
--ink-muted: #64748b;
--border-soft: var(--surface-divider);
--border-strong: var(--surface-border);
--tone-info: oklch(95% 0.035 250);
--tone-normal: oklch(95% 0.04 175);
--tone-warning: oklch(95% 0.05 70);
--tone-danger: oklch(95% 0.045 25);
--status-neutral-soft: oklch(96.9% 0.005 247.9);
--status-neutral-border: oklch(90.6% 0.013 244.3);
--status-neutral-foreground: oklch(46.5% 0.04 253.9);
--status-neutral-mark: oklch(66% 0.039 253.6);
--status-info-soft: oklch(97.1% 0.011 256.7);
--status-info-border: oklch(90.7% 0.031 252.7);
--status-info-foreground: oklch(48.9% 0.124 258);
--status-info-mark: oklch(59.7% 0.124 257.2);
--status-success-soft: oklch(97.2% 0.011 158.8);
--status-success-border: oklch(90.4% 0.031 159);
--status-success-foreground: oklch(51.2% 0.086 163.6);
--status-success-mark: oklch(62% 0.107 162);
--status-warning-soft: oklch(97.2% 0.016 82.8);
--status-warning-border: oklch(89.5% 0.049 77.2);
--status-warning-foreground: oklch(51.8% 0.105 62.8);
--status-warning-mark: oklch(67.2% 0.126 65.2);
--status-danger-soft: oklch(96.5% 0.01 17.3);
--status-danger-border: oklch(87.8% 0.031 17.8);
--status-danger-foreground: oklch(50.5% 0.118 22.2);
--status-danger-mark: oklch(62.6% 0.142 22);
--tone-agent: oklch(95% 0.035 290);
--scrim-soft: rgba(15, 23, 42, 0.2);
--scrim-focus: rgba(15, 23, 42, 0.48);
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
@@ -134,36 +222,36 @@
--radius: 0.5rem;
}
[data-basemap-tone="light"] {
--acrylic-navigation: rgba(242, 248, 252, 0.68);
--acrylic-panel: rgba(232, 241, 249, 0.7);
--acrylic-control: rgba(249, 251, 255, 0.78);
--acrylic-panel-blur: 24px;
--acrylic-control-blur: 20px;
--acrylic-backdrop-saturation: 1.22;
--acrylic-exclusion: rgba(255, 255, 255, 0.04);
--acrylic-luminosity: rgba(255, 255, 255, 0.18);
--surface-control: rgba(252, 253, 255, 0.95);
--surface-well: rgba(232, 240, 248, 0.34);
--surface-reading: rgba(255, 255, 255, 0.97);
--glass-transient: rgba(238, 247, 253, 0.56);
}
[data-basemap-tone="satellite"] {
--acrylic-navigation: rgba(242, 248, 252, 0.82);
--acrylic-panel: rgba(232, 241, 249, 0.7);
--acrylic-control: rgba(250, 252, 255, 0.9);
--acrylic-panel-blur: 20px;
--acrylic-control-blur: 16px;
--acrylic-backdrop-saturation: 1.1;
--acrylic-exclusion: rgba(255, 255, 255, 0.06);
--acrylic-luminosity: rgba(255, 255, 255, 0.24);
--surface-control: rgba(252, 253, 255, 0.98);
--surface-well: rgba(232, 240, 248, 0.28);
--surface-reading: rgba(255, 255, 255, 0.99);
--glass-transient: rgba(238, 247, 253, 0.72);
--acrylic-outline: rgba(71, 85, 105, 0.24);
--acrylic-shadow: 0 14px 38px rgba(15, 23, 42, 0.2);
--acrylic-navigation: rgba(232, 241, 249, 0.8);
--acrylic-panel: rgba(220, 232, 243, 0.72);
--acrylic-control: rgba(241, 247, 252, 0.86);
--agent-floating-reading-surface: rgba(228, 239, 247, 0.88);
--agent-floating-reading-item: rgba(247, 251, 254, 0.78);
--agent-floating-reading-item-hover: rgba(249, 252, 255, 0.92);
--agent-floating-reading-active: rgba(219, 234, 254, 0.88);
--agent-user-message-surface: rgba(222, 227, 249, 0.94);
--agent-user-message-border: rgba(79, 70, 229, 0.28);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.9);
--agent-operational-surface: rgba(255, 255, 255, 0.9);
--acrylic-panel-blur: 24px;
--acrylic-control-blur: 18px;
--acrylic-backdrop-saturation: 1.02;
--acrylic-exclusion: rgba(255, 255, 255, 0.04);
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
--surface-control: rgba(242, 248, 253, 0.96);
--surface-well: rgba(217, 230, 241, 0.78);
--surface-reading: rgba(252, 253, 255, 0.985);
--glass-transient: rgba(232, 242, 250, 0.76);
--acrylic-outline: rgba(43, 63, 86, 0.28);
--shadow-panel: 0 14px 36px rgba(7, 18, 32, 0.22);
--shadow-agent-history-bottom: 0 14px 14px -14px rgba(7, 18, 32, 0.4);
--shadow-control: 0 7px 20px rgba(7, 18, 32, 0.16);
--shadow-transient: 0 16px 38px rgba(7, 18, 32, 0.26);
--shadow-agent-floating:
0 20px 46px rgba(7, 18, 32, 0.26), 0 5px 14px rgba(7, 18, 32, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.68);
--shadow-agent-operational: 0 14px 36px rgba(7, 18, 32, 0.2), 0 4px 12px rgba(7, 18, 32, 0.12);
}
* {
@@ -177,26 +265,102 @@ body {
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
"PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
font-family: "Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #0f172a;
outline-offset: 2px;
}
button {
touch-action: manipulation;
}
button:not([data-slot="button"]) {
transition-duration: 120ms;
transition-property: color, background-color, border-color, box-shadow, filter, outline-color;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
button:not([data-slot="button"]):focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 2px;
}
button:not([data-slot="button"]):not(:disabled):active {
filter: brightness(0.97) saturate(1.02);
outline: 1px solid var(--action-border-pressed);
outline-offset: -1px;
}
button.action-control[aria-pressed="true"]:not(:disabled) {
border-color: var(--action-blue);
background-color: var(--action-blue);
color: #ffffff;
}
@media (hover: hover) {
button.action-control[aria-pressed="true"]:not(:disabled):hover {
border-color: var(--action-blue-hover);
background-color: var(--action-blue-hover);
color: #ffffff;
}
}
button.action-control[aria-pressed="true"]:not(:disabled):active {
border-color: var(--action-blue-pressed);
background-color: var(--action-blue-pressed);
color: #ffffff;
filter: none;
}
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled) {
border-color: var(--action-selection-border);
background-color: var(--action-selection-soft);
color: var(--action-blue);
}
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled)
.header-control-icon {
background-color: rgba(255, 255, 255, 0.68);
color: #3b82f6;
}
@media (hover: hover) {
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover {
border-color: var(--action-border-hover);
background-color: var(--action-selection-soft-hover);
color: var(--action-blue);
}
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover
.header-control-icon {
background-color: rgba(255, 255, 255, 0.82);
color: var(--action-blue);
}
}
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):active {
border-color: var(--action-border-pressed);
background-color: var(--action-selection-soft-pressed);
color: var(--action-blue-hover);
box-shadow: var(--action-shadow-secondary-pressed);
filter: none;
}
button:disabled {
cursor: not-allowed;
}
.map-grid {
background:
linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.08)),
linear-gradient(90deg, var(--canvas-map-grid) 1px, transparent 1px),
linear-gradient(var(--canvas-map-grid) 1px, transparent 1px),
var(--canvas-map);
linear-gradient(var(--canvas-map-grid) 1px, transparent 1px), var(--canvas-map);
background-size:
auto,
68px 68px,
@@ -208,10 +372,6 @@ input:focus-visible {
.acrylic-panel,
.acrylic-control,
.glass-transient,
.material-tone-info,
.material-tone-normal,
.material-tone-warning,
.material-tone-danger,
.material-tone-agent {
border-color: var(--surface-border);
background-clip: padding-box;
@@ -224,28 +384,34 @@ input:focus-visible {
border-color: var(--acrylic-border);
background-image:
linear-gradient(var(--acrylic-exclusion), var(--acrylic-exclusion)),
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)),
var(--acrylic-noise);
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)), var(--acrylic-noise);
background-blend-mode: exclusion, luminosity, soft-light;
box-shadow:
inset 0 1px 0 var(--acrylic-highlight),
0 0 0 1px var(--acrylic-outline),
var(--acrylic-shadow);
var(--shadow-panel);
}
.acrylic-navigation {
background-color: var(--acrylic-navigation);
box-shadow:
0 1px 0 var(--acrylic-outline),
0 8px 24px rgba(15, 23, 42, 0.08);
box-shadow: var(--shadow-navigation);
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur))
saturate(var(--acrylic-backdrop-saturation));
}
.acrylic-panel {
background-color: var(--acrylic-panel);
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur))
saturate(var(--acrylic-backdrop-saturation));
}
.agent-panel-shell.acrylic-panel,
.mobile-workbench-sheet:has(.agent-panel-shell) {
background-color: var(--agent-floating-reading-surface);
backdrop-filter: var(--agent-floating-acrylic-backdrop);
-webkit-backdrop-filter: var(--agent-floating-acrylic-backdrop);
}
.acrylic-control {
@@ -253,9 +419,10 @@ input:focus-visible {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 0 0 1px rgba(59, 130, 246, 0.14),
0 14px 34px rgba(15, 23, 42, 0.13);
var(--shadow-control);
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-control-blur))
saturate(var(--acrylic-backdrop-saturation));
}
@layer components {
@@ -267,10 +434,18 @@ input:focus-visible {
background-clip: padding-box;
}
.surface-dock { background-color: var(--surface-dock); }
.surface-control { background-color: var(--surface-control); }
.surface-well { background-color: var(--surface-well); }
.surface-reading { background-color: var(--surface-reading); }
.surface-dock {
background-color: var(--surface-dock);
}
.surface-control {
background-color: var(--surface-control);
}
.surface-well {
background-color: var(--surface-well);
}
.surface-reading {
background-color: var(--surface-reading);
}
}
.glass-transient {
@@ -278,29 +453,10 @@ input:focus-visible {
box-shadow:
inset 0 1px 0 var(--acrylic-highlight),
0 0 0 1px var(--acrylic-outline),
0 10px 28px rgba(15, 23, 42, 0.14);
var(--shadow-transient);
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
}
.material-tone-info {
background-color: var(--tone-info);
box-shadow: inset 0 1px 0 rgba(147, 197, 253, 0.42);
}
.material-tone-normal {
background-color: var(--tone-normal);
box-shadow: inset 0 1px 0 rgba(94, 234, 212, 0.4);
}
.material-tone-warning {
background-color: var(--tone-warning);
box-shadow: inset 0 1px 0 rgba(253, 186, 116, 0.46);
}
.material-tone-danger {
background-color: var(--tone-danger);
box-shadow: inset 0 1px 0 rgba(252, 165, 165, 0.48);
-webkit-backdrop-filter: blur(var(--acrylic-control-blur))
saturate(var(--acrylic-backdrop-saturation));
}
.material-tone-agent {
@@ -308,7 +464,96 @@ input:focus-visible {
box-shadow: inset 0 1px 0 rgba(196, 181, 253, 0.42);
}
.status-tone-neutral {
--status-soft: var(--status-neutral-soft);
--status-border: var(--status-neutral-border);
--status-foreground: var(--status-neutral-foreground);
--status-mark: var(--status-neutral-mark);
}
.status-tone-info {
--status-soft: var(--status-info-soft);
--status-border: var(--status-info-border);
--status-foreground: var(--status-info-foreground);
--status-mark: var(--status-info-mark);
}
.status-tone-success {
--status-soft: var(--status-success-soft);
--status-border: var(--status-success-border);
--status-foreground: var(--status-success-foreground);
--status-mark: var(--status-success-mark);
}
.status-tone-warning {
--status-soft: var(--status-warning-soft);
--status-border: var(--status-warning-border);
--status-foreground: var(--status-warning-foreground);
--status-mark: var(--status-warning-mark);
}
.status-tone-danger {
--status-soft: var(--status-danger-soft);
--status-border: var(--status-danger-border);
--status-foreground: var(--status-danger-foreground);
--status-mark: var(--status-danger-mark);
}
.status-badge,
.status-icon {
border-color: var(--status-border);
background-color: var(--status-soft);
color: var(--status-foreground);
}
.status-icon[data-selected="true"] {
--status-border: var(--action-blue);
--status-soft: var(--action-blue);
--status-foreground: #ffffff;
}
.status-dot {
background-color: var(--status-mark);
}
.status-dot[data-activity="live"] {
animation: status-live 1.4s ease-in-out infinite;
}
.status-glyph[data-activity="loading"] {
animation: status-spin 0.9s linear infinite;
}
@keyframes status-live {
0%,
100% {
opacity: 0.48;
}
50% {
opacity: 1;
}
}
@keyframes status-spin {
to {
transform: rotate(360deg);
}
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
:root,
[data-basemap-tone] {
--agent-floating-reading-surface: rgba(250, 252, 255, 0.98);
--agent-floating-reading-item: rgba(250, 252, 255, 0.98);
--agent-floating-reading-item-hover: rgba(255, 255, 255, 0.98);
--agent-floating-reading-active: rgba(232, 241, 255, 0.98);
--agent-user-message-surface: rgba(238, 240, 252, 0.98);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.98);
--agent-operational-surface: rgba(255, 255, 255, 0.98);
--agent-floating-acrylic-backdrop: none;
}
.acrylic-navigation,
.acrylic-panel,
.acrylic-control,
@@ -321,6 +566,18 @@ input:focus-visible {
}
@media (prefers-reduced-transparency: reduce) {
:root,
[data-basemap-tone] {
--agent-floating-reading-surface: rgba(250, 252, 255, 0.98);
--agent-floating-reading-item: rgba(250, 252, 255, 0.98);
--agent-floating-reading-item-hover: rgba(255, 255, 255, 0.98);
--agent-floating-reading-active: rgba(232, 241, 255, 0.98);
--agent-user-message-surface: rgba(238, 240, 252, 0.98);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.98);
--agent-operational-surface: rgba(255, 255, 255, 0.98);
--agent-floating-acrylic-backdrop: none;
}
.acrylic-navigation,
.acrylic-panel,
.acrylic-control,
@@ -338,28 +595,25 @@ input:focus-visible {
background-image: none;
}
.material-tone-info {
background-color: #eff6ff;
}
.material-tone-normal {
background-color: #f0fdfa;
}
.material-tone-warning {
background-color: #fff7ed;
}
.material-tone-danger {
background-color: #fef2f2;
}
.material-tone-agent {
background-color: #f5f3ff;
}
}
@media (forced-colors: active) {
:root,
[data-basemap-tone] {
--agent-floating-reading-surface: Canvas;
--agent-floating-reading-item: Canvas;
--agent-floating-reading-item-hover: Canvas;
--agent-floating-reading-active: Highlight;
--agent-user-message-surface: Canvas;
--agent-user-message-border: CanvasText;
--agent-white-acrylic-surface: Canvas;
--agent-operational-surface: Canvas;
--agent-floating-acrylic-backdrop: none;
}
.acrylic-navigation,
.acrylic-panel,
.acrylic-control,
@@ -368,10 +622,6 @@ input:focus-visible {
.surface-control,
.surface-well,
.surface-reading,
.material-tone-info,
.material-tone-normal,
.material-tone-warning,
.material-tone-danger,
.material-tone-agent {
forced-color-adjust: none;
border-color: CanvasText;
@@ -382,6 +632,24 @@ input:focus-visible {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.status-badge,
.status-icon {
border-color: CanvasText;
background-color: Canvas;
color: CanvasText;
}
.status-dot {
background-color: CanvasText;
}
}
@media (prefers-reduced-motion: reduce) {
.status-dot[data-activity="live"],
.status-glyph[data-activity="loading"] {
animation: none;
}
}
.agent-panel-shell {
@@ -389,16 +657,118 @@ input:focus-visible {
}
.agent-panel-header {
position: relative;
z-index: 1;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 10px 24px rgba(15, 23, 42, 0.07);
box-shadow: inset 0 -1px 0 var(--surface-divider);
}
.agent-panel-band {
.agent-panel-header-expandable {
position: absolute;
isolation: isolate;
}
.agent-panel-floating-acrylic {
background-image:
linear-gradient(var(--acrylic-exclusion), var(--acrylic-exclusion)),
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)), var(--acrylic-noise);
background-blend-mode: exclusion, luminosity, soft-light;
background-clip: padding-box;
box-shadow: var(--shadow-agent-floating);
backdrop-filter: var(--agent-floating-acrylic-backdrop);
-webkit-backdrop-filter: var(--agent-floating-acrylic-backdrop);
}
.agent-panel-header-expanded {
z-index: 2;
border-color: var(--acrylic-outline);
border-style: solid;
border-width: 0 0 1px;
border-bottom-right-radius: var(--radius-panel);
border-bottom-left-radius: var(--radius-panel);
box-shadow: var(--shadow-agent-history-bottom);
}
.agent-panel-integrated-header {
inset-inline: -0.25rem;
top: -0.25rem;
}
.agent-panel-header-mobile {
top: -2rem;
padding-top: 2rem;
}
.agent-panel-operational-float > .agent-panel-control,
.agent-panel-history-extension .agent-panel-control,
.agent-panel-composer > .agent-panel-control {
border: 0;
background: transparent;
box-shadow: none;
}
.agent-panel-operational-float > .agent-panel-control {
border-radius: inherit;
}
.agent-panel-history-extension {
border: 0;
border-radius: 0 0 var(--radius-panel) var(--radius-panel);
background-color: var(--agent-floating-reading-surface);
box-shadow: none;
}
.agent-panel-composer,
.agent-conversation-scroll-button,
.agent-speech-selection-action {
background-color: var(--agent-white-acrylic-surface);
}
.agent-panel-operational-float,
.agent-panel-composer {
inset-inline: calc(1rem + var(--scrollbar-size) + 1px);
}
.agent-panel-operational-float,
.agent-panel-composer,
.agent-conversation-scroll-button,
.agent-speech-selection-action {
border: 0;
}
.agent-panel-operational-float {
top: 4.75rem;
background-color: var(--agent-operational-surface);
background-clip: padding-box;
box-shadow: var(--shadow-agent-operational);
backdrop-filter: var(--agent-floating-acrylic-backdrop);
-webkit-backdrop-filter: var(--agent-floating-acrylic-backdrop);
}
.agent-panel-history-extension .agent-history-item,
.agent-panel-history-extension .agent-history-state,
.agent-panel-operational-float .agent-brief-metric {
background-color: var(--agent-floating-reading-item);
}
@media (hover: hover) {
.agent-panel-history-extension .agent-history-item:hover {
background-color: var(--agent-floating-reading-item-hover);
}
}
.agent-panel-history-extension .agent-history-item-active {
background-color: var(--agent-floating-reading-active);
}
.agent-history-rename-input,
.agent-history-rename-input:focus-visible {
border-color: #cbd5e1;
outline: none;
box-shadow: none;
}
.agent-panel-band:not(.agent-panel-floating-acrylic) {
background: var(--surface-control);
}
@@ -406,11 +776,87 @@ input:focus-visible {
background: rgba(232, 240, 248, 0.12);
}
.agent-panel-conversation-canvas {
border-radius: var(--radius-control);
background: rgba(232, 240, 248, 0.18);
}
.agent-panel-conversation button:not([data-slot="button"]):not(:disabled):active {
outline: none;
}
.agent-panel-conversation-content {
padding-bottom: 10.5rem;
}
.agent-panel-conversation-content-has-brief {
padding-top: 13rem;
}
.agent-panel-conversation-content-empty {
padding-bottom: 13.5rem;
}
.agent-panel-message {
border: 1px solid var(--surface-divider);
background: var(--surface-reading);
}
.agent-panel-user-message {
border: 1px solid var(--agent-user-message-border);
background: var(--agent-user-message-surface);
color: var(--ink);
}
.agent-panel-message,
.agent-panel-user-message {
box-shadow: var(--action-shadow-secondary);
}
@media (hover: hover) {
.agent-conversation-scroll-button:hover,
.agent-speech-selection-action:hover {
background-color: var(--agent-white-acrylic-surface);
}
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
background-image: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
background-image: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@media (forced-colors: active) {
.agent-panel-header-expanded {
border-color: CanvasText;
box-shadow: none;
}
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
forced-color-adjust: none;
border-color: CanvasText;
background-color: Canvas;
background-image: none;
box-shadow: none;
color: CanvasText;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
.agent-streaming-response {
display: block;
--sd-animation: agent-stream-token-in;
@@ -466,79 +912,79 @@ input:focus-visible {
.agent-panel-icon-button {
border: 1px solid var(--surface-divider);
background: var(--surface-control);
box-shadow: none;
box-shadow: var(--action-shadow-secondary);
}
.agent-panel-icon-button:hover {
border-color: rgba(37, 99, 235, 0.26);
background: rgba(239, 246, 255, 0.94);
color: #1d4ed8;
@media (hover: hover) {
.agent-panel-icon-button:hover {
border-color: var(--action-border-hover);
background: var(--action-soft);
color: var(--action-blue-hover);
}
}
.agent-panel-primary-icon,
.agent-panel-primary-action {
border: 1px solid rgba(37, 99, 235, 0.24);
background: #2563eb;
.agent-panel-primary-icon {
border: 1px solid var(--action-blue);
background: var(--action-blue);
color: #ffffff;
box-shadow: none;
box-shadow: var(--action-shadow-primary);
}
.agent-panel-primary-icon:hover,
.agent-panel-primary-action:hover {
border-color: rgba(29, 78, 216, 0.34);
background: #1d4ed8;
@media (hover: hover) {
.agent-panel-primary-icon:hover {
border-color: var(--action-blue-hover);
background: var(--action-blue-hover);
color: #ffffff;
}
}
.agent-panel-primary-icon:disabled,
.agent-panel-primary-action:disabled {
.agent-panel-primary-icon:disabled {
background: #93c5fd;
color: rgba(255, 255, 255, 0.86);
}
.scheduled-feed-scroll,
.scheduled-feed-detail-scroll,
.agent-conversation-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.26) transparent;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
.scheduled-feed-scroll::-webkit-scrollbar,
.scheduled-feed-detail-scroll::-webkit-scrollbar,
.agent-conversation-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scheduled-feed-scroll::-webkit-scrollbar-track,
.scheduled-feed-detail-scroll::-webkit-scrollbar-track,
.agent-conversation-scroll::-webkit-scrollbar-track {
background: transparent;
border-radius: 9999px;
}
.scheduled-feed-scroll::-webkit-scrollbar-thumb,
.scheduled-feed-detail-scroll::-webkit-scrollbar-thumb,
.agent-conversation-scroll::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.24);
border-radius: 9999px;
}
.scheduled-feed-scroll:hover::-webkit-scrollbar-thumb,
.scheduled-feed-detail-scroll:hover::-webkit-scrollbar-thumb,
.agent-conversation-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.36);
.agent-conversation-scroll {
scrollbar-gutter: stable both-edges;
}
.scheduled-feed-panel-shell {
--scheduled-feed-timeline-width: calc(var(--workbench-condition-width) - 1.5rem - 2px);
transition:
width 180ms cubic-bezier(0.22, 1, 0.36, 1),
max-height 180ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 150ms ease-out;
}
.scheduled-feed-panel-expanded {
width: min(
var(--workbench-condition-expanded-width),
calc(100vw - var(--workbench-agent-current-width) - 100px)
);
}
@media (min-width: 1536px) {
.scheduled-feed-panel-expanded {
width: min(
var(--workbench-condition-expanded-width-wide),
calc(100vw - var(--workbench-agent-current-width) - 100px)
);
}
}
.scheduled-feed-mobile {
transition: none;
}
.mobile-workbench-sheet {
overscroll-behavior: contain;
touch-action: manipulation;
}
.scheduled-feed-detail-enter {
animation: scheduled-feed-detail-show 140ms ease-out 180ms both;
will-change: opacity;
@@ -554,7 +1000,9 @@ input:focus-visible {
}
.scheduled-feed-layout-expanded {
grid-template-columns: minmax(0, 1fr) 432px;
grid-template-columns:
minmax(0, 1fr)
var(--scheduled-feed-timeline-width);
column-gap: 0.75rem;
}
+100
View File
@@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const statusTones = ["neutral", "info", "success", "warning", "danger"] as const;
describe("semantic status language", () => {
it("keeps the production status foregrounds above 4.5:1", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
statusTones.forEach((tone) => {
const soft = readOklchToken(styles, `--status-${tone}-soft`);
const foreground = readOklchToken(styles, `--status-${tone}-foreground`);
expect(getOklchContrastRatio(foreground, soft), tone).toBeGreaterThanOrEqual(4.5);
});
});
it("defines the complete softened OKLCH token set", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
statusTones.forEach((tone) => {
expect(styles).toContain(`--status-${tone}-soft:`);
expect(styles).toContain(`--status-${tone}-border:`);
expect(styles).toContain(`--status-${tone}-foreground:`);
expect(styles).toContain(`--status-${tone}-mark:`);
expect(styles).toContain(`.status-tone-${tone}`);
});
});
it("stops shared status motion when reduced motion is requested", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
expect(styles).toMatch(
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.status-dot\[data-activity="live"\][\s\S]*?animation:\s*none/
);
});
it("does not derive Agent connection styling from localized copy", () => {
const agentHook = readFileSync(
join(process.cwd(), "src/features/workbench/hooks/use-workbench-agent.ts"),
"utf8"
);
const agentPanel = readFileSync(
join(process.cwd(), "src/features/agent/components/agent-command-panel.tsx"),
"utf8"
);
expect(agentPanel).not.toMatch(/statusLabel\.(?:includes|match|startsWith)/);
expect(agentPanel).not.toContain("getAgentConnection");
expect(agentHook).not.toContain("runtimeAvailability");
expect(agentPanel).toContain("AgentConnectionStatus");
});
});
type OklchColor = {
lightness: number;
chroma: number;
hue: number;
};
function readOklchToken(styles: string, token: string): OklchColor {
const match = styles.match(
new RegExp(`${token}:\\s*oklch\\((\\d+(?:\\.\\d+)?)%\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\)`)
);
if (!match) {
throw new Error(`Missing OKLCH token: ${token}`);
}
return {
lightness: Number(match[1]) / 100,
chroma: Number(match[2]),
hue: Number(match[3])
};
}
function getOklchContrastRatio(foreground: OklchColor, background: OklchColor) {
const foregroundLuminance = getOklchRelativeLuminance(foreground);
const backgroundLuminance = getOklchRelativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function getOklchRelativeLuminance({ lightness, chroma, hue }: OklchColor) {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
const l = lPrime ** 3;
const m = mPrime ** 3;
const s = sPrime ** 3;
const red = clampLinearRgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
const green = clampLinearRgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
const blue = clampLinearRgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function clampLinearRgb(value: number) {
return Math.min(1, Math.max(0, value));
}
+475 -37
View File
@@ -1,31 +1,270 @@
import { expect, test, type Locator } from "playwright/test";
import { expect, test, type Locator } from "@playwright/test";
test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => {
type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
async function waitForAnimations(locator: Locator) {
await locator.evaluate(async (element) => {
const animations = element
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all(
animations.map((animation) => animation.finished.catch(() => undefined))
);
});
}
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(250);
const topBar = page.locator("header.acrylic-navigation");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentComposer = agentPanel.locator(".agent-panel-composer");
await expect(topBar).toBeVisible();
await expect(agentPanel).toBeVisible();
await expect(agentHeader).toBeVisible();
await expect(agentComposer).toBeVisible();
await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar);
await expectAcrylicSurface(agentPanel);
await expectTransparentAcrylicContext(agentHeader);
await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation"));
await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band"));
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
await expectIntegratedHeader(agentHeader);
await expectContextualAcrylicSurface(agentComposer, {
backdropFilter: "blur(",
maximumAlpha: 0.9,
minimumAlpha: 0.84
});
await expectLightweightMaterialContext(agentConversation);
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox();
const composerBox = await agentComposer.boundingBox();
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
).toBeLessThanOrEqual(1);
expect(
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
).toBeLessThanOrEqual(1);
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
expect(
Math.abs(
composerBox!.x -
panelBox!.x -
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
)
).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await expect
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
.toEqual(beforeFocus);
});
async function expectAcrylicSurface(shell: Locator) {
const composition = await shell.evaluate((element) => {
async function expectIntegratedHeader(header: Locator) {
const composition = await header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius
};
});
expect(composition.backdropFilter).toBe("none");
expect(getAlpha(composition.backgroundColor)).toBe(0);
expect(composition.borderTopWidth).toBe("0px");
expect(composition.borderRadius).toBe("0px");
}
test("desktop Agent header expands into one attached acrylic history surface", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await page.mouse.move(720, 320);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, agentPanel);
await expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
const headerBox = await agentHeader.boundingBox();
const agentPanelBox = await agentPanel.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(headerBefore).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(agentPanelBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerBox!.y, headerBefore!.y);
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
expectRoundedValue(historyBox!.x, headerBox!.x);
expectRoundedValue(historyBox!.width, headerBox!.width);
expectRoundedBox(conversationAfter!, conversationBefore!);
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
const editingItem = renameInput.locator(
"xpath=ancestor::*[contains(@class, 'agent-history-item')][1]"
);
await expect(renameInput).toBeFocused();
await expect(renameInput).toHaveCSS("box-shadow", "none");
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
await history.getByRole("button", { name: "取消重命名" }).click();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await historyButton.click();
await expect(history).toBeVisible();
await page.mouse.click(720, 320);
await expect(history).toBeHidden();
});
test("mobile Agent mirrors the desktop floating history surface", async ({
page
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentPanel = page
.locator('aside[aria-label="Agent 命令面板"]')
.filter({ visible: true });
await expect(agentPanel).toBeVisible();
await waitForAnimations(agentPanel);
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
const sheetHandle = mobileSheet.locator(".mobile-workbench-sheet-handle");
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.getByRole("log");
const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录"
});
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
const mobileSheetFrameBefore = await expectAcrylicOutline(mobileSheet);
await historyButton.click();
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
await waitForAnimations(history);
await page.mouse.move(10, 100);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, mobileSheet);
await expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(mobileSheet)).toEqual(mobileSheetFrameBefore);
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
await expect(sheetHandle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const panelBox = await agentPanel.boundingBox();
const headerAfter = await agentHeader.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(panelBox).not.toBeNull();
expect(headerBefore).not.toBeNull();
expect(headerAfter).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerAfter!.y, headerBefore!.y);
expectRoundedValue(headerAfter!.x, panelBox!.x);
expectRoundedValue(headerAfter!.y, panelBox!.y - 28);
expectRoundedValue(headerAfter!.width, panelBox!.width);
expect(headerAfter!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(historyBox!.width, headerAfter!.width);
expect(headerAfter!.height).toBeLessThan(panelBox!.height);
expectRoundedBox(conversationAfter!, conversationBefore!);
await expect(agentConversation).toHaveClass(/agent-panel-conversation-canvas/);
await expect(agentPanel.locator(".agent-panel-composer")).toBeVisible();
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
});
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const composer = page.locator(".agent-panel-composer");
await expect(composer).toBeVisible();
await expect(composer).toHaveCSS("backdrop-filter", "none");
await expect(composer).toHaveCSS("background-image", "none");
await expect(composer).toHaveCSS("box-shadow", "none");
});
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
const composition = await shell.evaluate((element, allowedSelector) => {
const style = getComputedStyle(element);
const filteredDescendants: string[] = [];
for (const descendant of element.querySelectorAll("*")) {
if (getComputedStyle(descendant).backdropFilter !== "none") {
if (
getComputedStyle(descendant).backdropFilter !== "none" &&
!(allowedSelector && descendant.matches(allowedSelector))
) {
filteredDescendants.push(descendant.className);
}
}
@@ -37,7 +276,7 @@ async function expectAcrylicSurface(shell: Locator) {
transform: style.transform,
willChange: style.willChange
};
});
}, allowedFilteredDescendant);
expect(composition.backdropFilter).toContain("blur(");
expect(getAlpha(composition.backgroundColor)).toBeLessThan(1);
@@ -46,50 +285,199 @@ async function expectAcrylicSurface(shell: Locator) {
expect(composition.willChange).toBe("auto");
}
async function expectContextualAcrylicSurface(
context: Locator,
{
backdropFilter = "none",
maximumAlpha = 0.7,
minimumAlpha = 0.35
}: {
backdropFilter?: string;
maximumAlpha?: number;
minimumAlpha?: number;
} = {}
) {
const composition = await context.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
});
if (backdropFilter === "none") {
expect(composition.backdropFilter).toBe("none");
} else {
expect(composition.backdropFilter).toContain(backdropFilter);
}
expect(getAlpha(composition.backgroundColor)).toBeGreaterThan(minimumAlpha);
expect(getAlpha(composition.backgroundColor)).toBeLessThan(maximumAlpha);
expect(composition.backgroundImage).not.toBe("none");
expect(composition.boxShadow).not.toBe("none");
}
async function expectAttachedHistoryExtension(history: Locator) {
const presentation = await history.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
borderTopLeftRadius: style.borderTopLeftRadius,
borderTopRightRadius: style.borderTopRightRadius,
boxShadow: style.boxShadow
};
});
expect(presentation.backdropFilter).toContain("blur(");
expect(getAlpha(presentation.backgroundColor)).toBeGreaterThanOrEqual(0.78);
expect(getAlpha(presentation.backgroundColor)).toBeLessThanOrEqual(0.9);
expect(presentation.backgroundImage).not.toBe("none");
expect(presentation.borderBottomWidth).toBe("0px");
expect(presentation.borderLeftWidth).toBe("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.borderTopLeftRadius).toBe("0px");
expect(presentation.borderTopRightRadius).toBe("0px");
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.boxShadow).toBe("none");
}
async function getAcrylicFrame(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderBottomColor: style.borderBottomColor,
borderLeftColor: style.borderLeftColor,
borderRightColor: style.borderRightColor,
borderTopColor: style.borderTopColor,
boxShadow: style.boxShadow
};
});
}
async function expectAcrylicOutline(surface: Locator) {
const frame = await getAcrylicFrame(surface);
expect(frame.borderTopColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderRightColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderBottomColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.borderLeftColor).not.toBe("rgba(0, 0, 0, 0)");
expect(frame.boxShadow).toContain("0px 1px 0px 0px inset");
expect(frame.boxShadow).toContain("0px 0px 0px 1px");
return frame;
}
async function expectUnifiedHistoryStructure(history: Locator) {
const structure = await history.evaluate((element) => {
const paragraphs = Array.from(element.querySelectorAll("p"));
const heading = paragraphs.find((paragraph) => paragraph.textContent?.trim() === "历史记录");
const count = paragraphs.find((paragraph) =>
/^(?:\d+ 个会话|暂无历史会话)$/.test(paragraph.textContent?.trim() ?? "")
);
return {
countTagName: count?.tagName ?? null,
headingTagName: heading?.tagName ?? null,
siblings: Boolean(heading && count && heading.parentElement === count.parentElement)
};
});
expect(structure).toEqual({
countTagName: "P",
headingTagName: "P",
siblings: true
});
await expect(history.getByRole("button", { name: "重命名对话" }).first()).toBeVisible();
await expect(history.getByRole("button", { name: "删除对话" }).first()).toBeVisible();
}
async function expectTransparentExpandedHeader(header: Locator, agentSurface: Locator) {
const [presentation, agentOutlineColor] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderBottomColor: style.borderBottomColor,
borderBottomLeftRadius: style.borderBottomLeftRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomWidth: style.borderBottomWidth,
borderLeftWidth: style.borderLeftWidth,
borderRightWidth: style.borderRightWidth,
borderTopWidth: style.borderTopWidth,
boxShadow: style.boxShadow
};
}),
agentSurface.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
)
]);
expect(getAlpha(presentation.backgroundColor)).toBe(0);
expect(presentation.borderBottomColor).toBe(agentOutlineColor);
expect(presentation.borderBottomLeftRadius).toBe("16px");
expect(presentation.borderBottomRightRadius).toBe("16px");
expect(presentation.borderBottomWidth).toBe("1px");
expect(presentation.borderLeftWidth).toBe("0px");
expect(presentation.borderRightWidth).toBe("0px");
expect(presentation.borderTopWidth).toBe("0px");
expect(presentation.boxShadow).toContain("0px 12px 12px -12px");
}
async function getSurfacePresentation(surface: Locator) {
return surface.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
color: style.color
};
});
}
function getAlpha(color: string) {
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
return match ? Number(match[1]) : 1;
}
async function expectTransparentAcrylicContext(header: Locator) {
const style = await header.evaluate((element) => {
async function expectLightweightMaterialContext(context: Locator) {
const style = await context.evaluate((element) => {
const computedStyle = getComputedStyle(element);
return {
backdropFilter: computedStyle.backdropFilter,
backgroundColor: computedStyle.backgroundColor,
className: element.className,
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
};
});
expect(style.backdropFilter).toBe("none");
expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(getAlpha(style.backgroundColor)).toBeGreaterThan(0);
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.3);
expect(["", "none"]).toContain(style.webkitBackdropFilter);
expect(String(style.className)).not.toContain("acrylic-");
}
async function expectLightweightContextSurfaces(surfaces: Locator) {
const surfaceStyles = await surfaces.evaluateAll((elements) =>
elements.map((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
})
);
expect(surfaceStyles.length).toBeGreaterThan(0);
for (const style of surfaceStyles) {
expect(style.backdropFilter).toBe("none");
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12);
}
}
async function expectSolidInternalSurfaces(surfaces: Locator) {
async function expectUnfilteredInternalSurfaces(surfaces: Locator) {
const surfaceStyles = await surfaces.evaluateAll((elements) =>
elements.map((element) => {
const style = getComputedStyle(element);
@@ -109,3 +497,53 @@ async function expectSolidInternalSurfaces(surfaces: Locator) {
expect(String(style.className)).not.toContain("acrylic-");
}
}
function expectRoundedBox(actual: BoundingBox, expected: BoundingBox) {
expect(Math.round(actual.x)).toBe(Math.round(expected.x));
expect(Math.round(actual.y)).toBe(Math.round(expected.y));
expect(Math.round(actual.width)).toBe(Math.round(expected.width));
expect(Math.round(actual.height)).toBe(Math.round(expected.height));
}
function expectRoundedValue(actual: number, expected: number) {
expect(Math.abs(Math.round(actual) - Math.round(expected))).toBeLessThanOrEqual(1);
}
async function getFloatingSurfaceStyles(header: Locator, composer: Locator) {
const control = composer.locator(".agent-panel-control");
const [headerStyles, composerStyles, controlStyles] = await Promise.all([
header.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
composer.evaluate((element) => {
const style = getComputedStyle(element);
return {
shadow: style.boxShadow,
transform: style.transform
};
}),
control.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderColor: style.borderColor,
boxShadow: style.boxShadow
};
})
]);
return {
composerShadow: composerStyles.shadow,
composerTransform: composerStyles.transform,
controlBorderColor: controlStyles.borderColor,
controlBoxShadow: controlStyles.boxShadow,
headerShadow: headerStyles.shadow,
headerTransform: headerStyles.transform
};
}
+24 -12
View File
@@ -1,6 +1,6 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
@@ -16,13 +16,15 @@ test("Agent panel resizes by drag without exceeding half the viewport", async ({
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
await page.mouse.up();
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
await resizeHandle.focus();
await page.keyboard.press("Home");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
await page.keyboard.press("ArrowRight");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
await page.keyboard.press("End");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
});
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
@@ -57,21 +59,31 @@ test.describe("mobile touch layout", () => {
const buttonBox = await openButton.boundingBox();
expect(buttonBox).not.toBeNull();
await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2);
await page.touchscreen.tap(
buttonBox!.x + buttonBox!.width / 2,
buttonBox!.y + buttonBox!.height / 2
);
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
});
});
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
// Temporarily disabled: this regression flow would start a real Agent run.
// Agent analysis must be explicitly triggered by a user, not by automated tests.
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await page.getByRole("button", { name: "工况汇总" }).click();
// Agent execution must remain an explicit user action.
// await page.getByRole("button", { name: "工况汇总" }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
});
+153 -36
View File
@@ -1,6 +1,6 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -276,6 +276,48 @@ test("stream completion and the plan card remain positionally stable", async ({
});
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "rgba(224, 229, 249, 0.92)");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
await assistantMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
);
await expect
.poll(() => userMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
const operationalBrief = page.locator(".agent-panel-operational-float");
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toHaveCSS("position", "absolute");
await expect(operationalBrief).not.toHaveClass(/agent-panel-floating-acrylic/);
await expect(operationalBrief).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(operationalBrief).toHaveCSS("border-radius", "16px");
await expect(operationalBrief).toHaveCSS("background-image", "none");
await expect(operationalBrief).toHaveCSS("border-top-width", "0px");
await expect(operationalBrief).toHaveCSS(
"box-shadow",
"rgba(15, 33, 55, 0.14) 0px 12px 32px 0px, rgba(15, 33, 55, 0.08) 0px 3px 10px 0px"
);
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toContain("inset");
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).backdropFilter))
.toContain("blur(");
const operationalBriefBox = await operationalBrief.boundingBox();
const conversationBox = await page.locator(".agent-panel-conversation-canvas").boundingBox();
expect(operationalBriefBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(operationalBriefBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
await expect(page.locator(".agent-panel-conversation-content-has-brief")).toHaveCSS(
"padding-top",
"208px"
);
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
@@ -393,9 +435,19 @@ test("stream completion and the plan card remain positionally stable", async ({
.filter({ visible: true })
.first();
await expect(progressToggle).toBeEnabled();
await expect(progressToggle).toHaveAttribute("data-state", "closed");
await expect
.poll(async () => (await progressToggle.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(40);
await progressToggle.click();
await expect(progressToggle).toHaveAttribute("data-state", "open");
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
"color",
"oklch(0.546 0.245 262.881)"
);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true })
@@ -403,34 +455,9 @@ test("stream completion and the plan card remain positionally stable", async ({
await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
await page.evaluate(() => {
const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
const startedAt = performance.now();
state.__agentFailureMotionObserved = false;
const sample = () => {
const badge = document.querySelector<HTMLElement>('[data-progress-status="error"]');
if (badge && getComputedStyle(badge).transform !== "none") {
state.__agentFailureMotionObserved = true;
}
if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
triggerProgressFailure();
await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { __agentFailureMotionObserved?: boolean })
.__agentFailureMotionObserved ?? false
)
)
.toBe(true);
await expect(page.locator('[data-progress-status="error"]')).toHaveCSS("transform", "none");
resumeStream();
await expect(
@@ -438,7 +465,7 @@ test("stream completion and the plan card remain positionally stable", async ({
).toBeVisible({
timeout: 10_000
});
await expect(progressToggle).toContainText("共 4 条");
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })
@@ -528,24 +555,114 @@ test("stream completion and the plan card remain positionally stable", async ({
.getByRole("button", { name: /执行进度/ })
.filter({ visible: true })
.last();
const latestProgressRegion = latestProgressToggle.locator("..");
const latestProgressRegion = latestProgressToggle.locator("../..");
const latestExpandedProgress = latestProgressRegion.getByRole("list", {
name: "全部执行进度",
name: "全部执行进度"
});
await expect(latestProgressToggle).toContainText("最近 3 条");
await expect(latestProgressRegion).toContainText("最近3/3");
triggerProgressAppend();
await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
).toBeVisible();
await latestProgressToggle.click();
await expect(latestProgressToggle).toContainText("全部 4 条");
await expect(latestProgressRegion).toContainText("全部4/4");
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible();
await expect(
latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover();
await page.mouse.wheel(0, -2400);
const scrollToLatestButton = page.getByRole("button", { name: "滚动到最新消息" });
await expect(scrollToLatestButton).toBeVisible();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
)
.toContain("blur(");
await expect
.poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
const readAcrylicMaterial = (element: HTMLElement) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
};
expect(await scrollToLatestButton.evaluate(readAcrylicMaterial)).toEqual(
await page.locator(".agent-panel-composer").evaluate(readAcrylicMaterial)
);
const selectableAssistantMessage = page.getByRole("heading", { name: "诊断结果" }).first();
await selectableAssistantMessage.evaluate((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let textNode = walker.nextNode();
while (textNode && !textNode.textContent?.trim()) {
textNode = walker.nextNode();
}
if (!textNode?.textContent) {
throw new Error("Assistant message does not contain selectable text");
}
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(8, textNode.textContent.length));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
const speechSelectionIconMotion = await page.evaluate(async () => {
const waitForAnimationFrame = () =>
new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
const deadline = performance.now() + 1_000;
let button: HTMLButtonElement | undefined;
while (!button && performance.now() < deadline) {
button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((element) =>
element.textContent?.includes("从这里开始朗读")
);
if (!button) await waitForAnimationFrame();
}
const icon = button?.querySelector("svg");
if (!button || !icon) {
throw new Error("Speech selection action did not render with an icon");
}
const samples: Array<{ x: number; y: number }> = [];
const startedAt = performance.now();
while (performance.now() - startedAt < 220) {
const rect = icon.getBoundingClientRect();
samples.push({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2
});
await waitForAnimationFrame();
}
const xValues = samples.map((sample) => sample.x);
const yValues = samples.map((sample) => sample.y);
return {
xSpread: Math.max(...xValues) - Math.min(...xValues),
ySpread: Math.max(...yValues) - Math.min(...yValues)
};
});
expect(speechSelectionIconMotion.xSpread).toBeLessThanOrEqual(0.1);
expect(speechSelectionIconMotion.ySpread).toBeLessThanOrEqual(0.1);
const speechSelectionButton = page.getByRole("button", { name: "从这里开始朗读" });
await expect(speechSelectionButton).toBeVisible();
await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px");
expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual(
await scrollToLatestButton.evaluate(readAcrylicMaterial)
);
});
function createLongMarkdownResponse() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
const hydrationErrors: string[] = [];
+172
View File
@@ -0,0 +1,172 @@
import { expect, test, type Page } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await prepareWorkbench(page);
});
test("desktop controls share focus, hover, press and open states", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const scenario = page.getByRole("button", { name: "切换模拟方案" });
await expect(scenario).toBeVisible();
await expect.poll(async () => (await scenario.boundingBox())?.height).toBe(32);
const compactHitArea = await scenario.evaluate(
(element) => getComputedStyle(element, "::after").inset
);
expect(compactHitArea).toBe("-4px");
await scenario.focus();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await scenario.hover();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)");
const pressTarget = page.getByRole("button", { name: /任务浮条/ });
const restingBackground = await pressTarget.evaluate(
(element) => getComputedStyle(element).backgroundColor
);
expect(restingBackground).toBe("rgb(232, 241, 255)");
await pressTarget.hover();
const box = await pressTarget.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await expect
.poll(async () => pressTarget.evaluate((element) => element.matches(":active")))
.toBe(true);
await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).filter))
.toBe("none");
const pressedBox = await pressTarget.boundingBox();
expect(pressedBox).toEqual(box);
await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe("rgb(207, 226, 255)");
await page.mouse.move(720, 460);
await page.mouse.up();
await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).filter))
.toBe("none");
await expect(pressTarget).toHaveAttribute("aria-pressed", "true");
await pressTarget.hover();
const hoverBackground = await pressTarget.evaluate((element) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "var(--action-selection-soft-hover)";
element.append(probe);
const backgroundColor = getComputedStyle(probe).backgroundColor;
probe.remove();
return backgroundColor;
});
await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(hoverBackground);
await page.mouse.move(720, 460);
await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(restingBackground);
await page.getByRole("button", { name: "打开用户菜单" }).click();
const userButton = page.getByRole("button", { name: "打开用户菜单" });
await expect(userButton).toHaveAttribute("data-state", "open");
await expect
.poll(async () => userButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)");
const mapTool = page.getByRole("button", { name: /图层:管理地图图层/ });
const mapToolBox = await mapTool.boundingBox();
expect(mapToolBox?.width).toBe(40);
expect(mapToolBox?.height).toBe(40);
});
test("mobile launchers and map dock keep non-overlapping touch targets", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const agentLauncher = page.getByRole("button", { name: "打开 Agent 面板" });
const conditionLauncher = page.getByRole("button", { name: "打开工况任务" });
const mapTools = page.locator('nav[aria-label="地图工具"]:visible button:visible');
for (const locator of [agentLauncher, conditionLauncher]) {
const box = await locator.boundingBox();
expect(box?.width).toBeGreaterThanOrEqual(44);
expect(box?.height).toBeGreaterThanOrEqual(44);
}
const toolCount = await mapTools.count();
expect(toolCount).toBeGreaterThan(0);
for (let index = 0; index < toolCount; index += 1) {
const box = await mapTools.nth(index).boundingBox();
expect(box?.width).toBeGreaterThanOrEqual(40);
expect(box?.height).toBeGreaterThanOrEqual(40);
}
const rectangles = await Promise.all(
[agentLauncher, mapTools.first(), conditionLauncher].map((locator) => locator.boundingBox())
);
expect(rectangles.every(Boolean)).toBe(true);
expect(rectangles[0]!.x + rectangles[0]!.width).toBeLessThanOrEqual(rectangles[1]!.x);
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
});
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => {
const conversation = document.createElement("div");
conversation.className = "agent-panel-conversation";
Object.assign(conversation.style, {
position: "fixed",
inset: "100px auto auto 100px",
zIndex: "9999"
});
const button = document.createElement("button");
button.type = "button";
button.textContent = "测试内容按钮";
Object.assign(button.style, { width: "120px", height: "40px" });
conversation.append(button);
document.body.append(conversation);
});
const contentButton = page.getByRole("button", { name: "测试内容按钮" });
const box = await contentButton.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await expect
.poll(async () => contentButton.evaluate((element) => element.matches(":active")))
.toBe(true);
await expect(contentButton).toHaveCSS("outline-style", "none");
await page.mouse.up();
});
async function prepareWorkbench(page: Page) {
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
});
});
await page.route("https://button-system.invalid/**", async (route) => {
await route.fulfill({ status: 204, body: "" });
});
await page.route("https://demotiles.maplibre.org/**", async (route) => {
await route.fulfill({ status: 204, body: "" });
});
await page.route("**/*.riv", async (route) => {
await route.abort();
});
}
+57 -2
View File
@@ -1,8 +1,11 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => {
test("Dev Panel opens as a floating desktop panel and closes with Escape", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("scada-hit")
));
const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" });
await expect(trigger).toBeVisible();
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible();
@@ -11,10 +14,25 @@ test("Dev Panel opens on desktop, owns the right workspace, and closes with Esca
const panel = page.getByTestId("map-dev-panel");
await expect(panel).toBeVisible();
await expect(panel).toHaveCSS("width", "400px");
await expect(panel).toHaveCSS("border-radius", "16px");
const panelBox = await panel.boundingBox();
expect(panelBox).not.toBeNull();
expect(panelBox!.x).toBe(1028);
expect(panelBox!.y).toBe(72);
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(884);
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
await panel.getByLabel("管道 ID").fill("P-9");
await panel.getByLabel("有符号流速").fill("-2.75");
await panel.getByRole("button", { name: "更新该管道方向" }).click();
await expect(panel.getByRole("status")).toContainText(
"已赋值 1 · 正向 0 · 反向 1 · 停止 0"
);
await panel.getByRole("button", { name: "开启水流" }).click();
await expect(panel.getByRole("button", { name: "关闭水流" })).toBeEnabled();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
@@ -25,3 +43,40 @@ test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
await expect(page.getByTestId("map-dev-panel")).toHaveCount(0);
});
test("labels remain active per business group and clear globally", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("workbench-value-label-pipes")
));
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
await apply.click();
await panel.getByLabel("业务图层组").selectOption("junctions");
await apply.click();
await expect.poll(() => page.evaluate(() => ({
pipes: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-pipes",
"visibility"
),
junctions: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-junctions",
"visibility"
)
}))).toEqual({ pipes: "visible", junctions: "visible" });
await panel.getByRole("button", { name: "清除全部标注" }).click();
await expect.poll(() => page.evaluate(() => ({
pipes: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-pipes",
"visibility"
),
junctions: window.__waterNetworkMap?.getLayoutProperty(
"workbench-value-label-junctions",
"visibility"
)
}))).toEqual({ pipes: "none", junctions: "none" });
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("map notice is centered in the mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from "@playwright/test";
test.describe("mobile workbench sheet", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("keeps the bottom launcher controls at one height", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const agentButton = page.getByRole("button", { name: "打开 Agent 面板" });
const mapToolbar = page.locator('nav[aria-label="地图工具"]:visible');
const conditionButton = page.getByRole("button", { name: "打开工况任务" });
const boxes = await Promise.all([
agentButton.boundingBox(),
mapToolbar.boundingBox(),
conditionButton.boundingBox()
]);
boxes.forEach((box) => expect(box).not.toBeNull());
expect(boxes.map((box) => Math.round(box!.height))).toEqual([44, 44, 44]);
expect(boxes.map((box) => Math.round(box!.y))).toEqual([
Math.round(boxes[0]!.y),
Math.round(boxes[0]!.y),
Math.round(boxes[0]!.y)
]);
});
test("supports half and full heights, keyboard control, and Escape close", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
const handle = page.getByRole("button", { name: /Agent 工作台抽屉高度/ });
const closeButton = page.getByRole("button", { name: "关闭 Agent 面板" });
await expect(sheet).toBeVisible();
await expect(closeButton).toHaveCount(1);
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
await expect(closeButton).toHaveCSS("border-radius", "12px");
await expect.poll(async () => Math.round((await closeButton.boundingBox())?.height ?? 0)).toBe(
40
);
await expect(sheet).toHaveAttribute("data-snap", "half");
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
await expect
.poll(async () =>
sheet.evaluate((element) => {
const style = getComputedStyle(element);
const nestedFilters = Array.from(element.querySelectorAll("*")).filter(
(descendant) => getComputedStyle(descendant).backdropFilter !== "none"
);
return {
backdropFilter: style.backdropFilter,
nestedFilterCount: nestedFilters.length,
opacity: style.opacity,
transform: style.transform
};
})
)
.toEqual({
backdropFilter: "blur(18px) saturate(1.08)",
nestedFilterCount: 1,
opacity: "1",
transform: "none"
});
await handle.click();
await expect(sheet).toHaveAttribute("data-snap", "full");
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(776);
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
await handle.focus();
await page.keyboard.press("Home");
await expect(sheet).toHaveAttribute("data-snap", "half");
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
await page.keyboard.press("ArrowUp");
await expect(sheet).toHaveAttribute("data-snap", "full");
await page.keyboard.press("ArrowDown");
await expect(sheet).toHaveAttribute("data-snap", "half");
await page.keyboard.press("End");
await expect(sheet).toHaveAttribute("data-snap", "full");
await page.setViewportSize({ width: 390, height: 700 });
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(632);
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
});
test("keeps Agent and condition sheets mutually exclusive", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
await expect(agentSheet).toBeVisible();
await expect.poll(async () => Math.round((await agentSheet.boundingBox())?.height ?? 0)).toBe(
439
);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "打开工况任务" }).click();
const conditionSheet = page.getByRole("region", { name: "工况任务抽屉" });
await expect(conditionSheet).toBeVisible();
await expect.poll(async () =>
Math.round((await conditionSheet.boundingBox())?.height ?? 0)
).toBe(439);
await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toHaveCount(0);
});
test("offers a touch close action for the condition sheet", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
const closeButton = page.getByRole("button", { name: "关闭工况任务" });
await expect(sheet).toBeVisible();
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
await expect(closeButton).toHaveCSS("border-radius", "12px");
await closeButton.click();
expect(await sheet.count()).toBe(0);
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
});
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await sheet.getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible();
const workflowTitle = sheet.getByText("SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:泵站边界复核复令" });
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await awaitingReplyWorkOrder.click();
await expect(sheet.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
});
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
await page.setViewportSize({ width: 1200, height: 800 });
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toHaveCount(0);
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
});
});
@@ -1,22 +1,254 @@
import { expect, test, type Locator } from "playwright/test";
import { expect, test, type Locator } from "@playwright/test";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toBeVisible();
await page.waitForTimeout(250);
await expectAcrylicComposition(panel);
await expect(panel).toHaveCSS("width", "432px");
const timeline = panel.getByTestId("scheduled-condition-timeline");
const detailViewport = panel.locator(".scheduled-feed-detail-viewport");
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const collapsedPanelBox = await panel.boundingBox();
const collapsedTimelineBox = await timeline.boundingBox();
expect(collapsedPanelBox).not.toBeNull();
expect(collapsedTimelineBox).not.toBeNull();
await panel.getByRole("button", { name: "展开工况任务" }).click();
await page.waitForTimeout(250);
await expectAcrylicComposition(panel);
await expect(panel).toHaveCSS("width", "880px");
const expandedPanelBox = await panel.boundingBox();
const expandedTimelineBox = await timeline.boundingBox();
const expandedDetailViewportBox = await detailViewport.boundingBox();
const expandedDetailScrollBox = await detailScroll.boundingBox();
expect(expandedPanelBox).not.toBeNull();
expect(expandedTimelineBox).not.toBeNull();
expect(expandedDetailViewportBox).not.toBeNull();
expect(expandedDetailScrollBox).not.toBeNull();
expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe(
Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width)
);
expect(Math.round(expandedTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.width)
);
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
);
expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual(
expandedPanelBox!.y + expandedPanelBox!.height - 12
);
expect(Math.round(expandedDetailViewportBox!.y + expandedDetailViewportBox!.height)).toBe(
Math.round(expandedTimelineBox!.y + expandedTimelineBox!.height)
);
expect(expandedDetailScrollBox!.y).toBeGreaterThanOrEqual(
expandedDetailViewportBox!.y + 8
);
expect(expandedDetailScrollBox!.y + expandedDetailScrollBox!.height).toBeLessThanOrEqual(
expandedDetailViewportBox!.y + expandedDetailViewportBox!.height - 8
);
await expect(detailViewport).toHaveCSS("border-radius", "12px");
const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1");
expect(await readScrollbarPresentation(detailScroll)).toEqual(
await readScrollbarPresentation(timelineScroll)
);
const futureScrollBox = await timeline.locator(".scheduled-feed-scroll").first().boundingBox();
const recentScrollBox = await timelineScroll.boundingBox();
expect(futureScrollBox).not.toBeNull();
expect(recentScrollBox).not.toBeNull();
expect(Math.round(futureScrollBox!.x + futureScrollBox!.width)).toBe(
Math.round(recentScrollBox!.x + recentScrollBox!.width)
);
await panel.getByRole("button", { name: "收起工况任务" }).click();
await expect(panel).toHaveCSS("width", "432px");
const restoredTimelineBox = await timeline.boundingBox();
expect(restoredTimelineBox).not.toBeNull();
expect(Math.round(restoredTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.width)
);
});
test("narrow desktop keeps Agent and expanded conditions from overlapping", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
await resizeHandle.focus();
await page.keyboard.press("End");
await expect(agentPanel).toHaveCSS("width", "484px");
const compactAgentBox = await agentPanel.boundingBox();
const compactConditionBox = await panel.boundingBox();
expect(compactAgentBox).not.toBeNull();
expect(compactConditionBox).not.toBeNull();
expect(compactConditionBox!.x - (compactAgentBox!.x + compactAgentBox!.width)).toBeGreaterThanOrEqual(
24
);
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await expect(panel).toHaveCSS("width", "852px");
const expandedConditionBox = await panel.boundingBox();
const collapsedAgentBox = await page
.locator('aside[aria-label="Agent 折叠栏"]')
.boundingBox();
expect(expandedConditionBox).not.toBeNull();
expect(collapsedAgentBox).not.toBeNull();
expect(
expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width)
).toBeGreaterThanOrEqual(24);
await panel.getByRole("button", { name: "收起工况任务" }).click();
await expect(agentPanel).toBeVisible();
});
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toHaveCSS("width", "880px");
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const baselineHeight = await detailScroll.evaluate(
(element) => element.getBoundingClientRect().height
);
const targetCondition = panel
.getByTestId("scheduled-condition-timeline")
.getByRole("button", { name: /SCADA 数据诊断/ })
.nth(1);
const sampledHeights = await targetCondition.evaluate(async (element) => {
element.click();
const heights: number[] = [];
const startedAt = performance.now();
await new Promise<void>((resolve) => {
const sample = () => {
const detail = document.querySelector<HTMLElement>(
'[data-testid="scheduled-condition-detail-scroll"]'
);
if (detail) {
heights.push(detail.getBoundingClientRect().height);
}
if (performance.now() - startedAt >= 650) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
return heights;
});
expect(sampledHeights.length).toBeGreaterThan(1);
expect(Math.min(...sampledHeights)).toBeGreaterThanOrEqual(baselineHeight - 1);
await panel
.getByTestId("scheduled-condition-timeline")
.getByRole("button", { name: /SCADA 数据诊断/ })
.first()
.click();
const workflowTitle = panel.getByText("SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
const timeline = panel.getByTestId("scheduled-condition-timeline");
const runningCondition = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "SCADA 数据诊断" })
.filter({ hasText: "执行中" })
.first();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:北辰分区阀门开度调整" });
await expectRunningStatusAnimations(runningCondition);
await runningCondition.click();
await expectRunningStatusAnimations(runningCondition);
await expect(
panel
.getByText("SCADA 诊断流程", { exact: true })
.locator("..")
.locator(".status-badge .status-dot")
).toHaveCount(0);
await expectRunningStatusAnimations(executingWorkOrder);
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
await executingWorkOrder.click();
await expectRunningStatusAnimations(executingWorkOrder);
});
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
const awaitingReplyWorkOrder = panel
.getByTestId("scheduled-condition-timeline")
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:泵站边界复核复令" });
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
"data-status-tone",
"warning"
);
await expect(awaitingReplyWorkOrder.locator(".status-glyph")).toHaveAttribute(
"data-activity",
"static"
);
await expect.poll(() => getAnimationName(awaitingReplyWorkOrder.locator(".status-glyph"))).toBe(
"none"
);
await awaitingReplyWorkOrder.click();
await expect(panel.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
await expect(
panel
.getByTestId("scheduled-condition-detail-scroll")
.locator('.status-badge[data-status-tone="warning"]')
.filter({ hasText: "待复令" })
.first()
).toHaveAttribute("data-status-tone", "warning");
});
async function readScrollbarPresentation(locator: Locator) {
return locator.evaluate((element) => {
const style = getComputedStyle(element);
const scrollbarStyle = getComputedStyle(element, "::-webkit-scrollbar");
const thumbStyle = getComputedStyle(element, "::-webkit-scrollbar-thumb");
return {
gutter: style.scrollbarGutter,
overflowY: style.overflowY,
scrollbarColor: style.scrollbarColor,
scrollbarWidth: style.scrollbarWidth,
thumbBackground: thumbStyle.backgroundColor,
thumbRadius: thumbStyle.borderRadius,
webkitWidth: scrollbarStyle.width
};
});
}
async function expectAcrylicComposition(panel: Locator) {
const composition = await panel.evaluate((element) => {
const rootStyle = getComputedStyle(element);
@@ -59,3 +291,15 @@ async function expectAcrylicComposition(panel: Locator) {
expect(composition.transform).toBe("none");
expect(composition.willChange).toBe("auto");
}
async function expectRunningStatusAnimations(row: Locator) {
const timelineGlyph = row.locator(".status-icon .status-glyph");
await expect(timelineGlyph).toHaveAttribute("data-activity", "loading");
await expect(row.locator(".status-badge .status-dot")).toHaveCount(0);
await expect.poll(() => getAnimationName(timelineGlyph)).toBe("status-spin");
}
async function getAnimationName(locator: Locator) {
return locator.evaluate((element) => getComputedStyle(element).animationName);
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test, type Locator } from "@playwright/test";
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const reference = page
.getByRole("region", { name: "工况任务", exact: true })
.getByTestId("scheduled-condition-timeline")
.locator(".scheduled-feed-scroll.flex-1");
await page.evaluate(() => {
const probe = document.createElement("div");
const content = document.createElement("div");
probe.dataset.testid = "global-scrollbar-probe";
probe.style.cssText =
"position:fixed;left:8px;bottom:8px;width:40px;height:40px;overflow:auto;z-index:9999";
content.style.cssText = "width:80px;height:80px";
probe.append(content);
document.body.append(probe);
});
const probe = page.getByTestId("global-scrollbar-probe");
const presentation = await readScrollbarPresentation(reference);
expect(await readScrollbarPresentation(probe)).toEqual(presentation);
expect(presentation).toMatchObject({
height: "4px",
thumbRadius: "2px",
trackRadius: "0px",
width: "4px"
});
await reference.hover();
const referenceHoverThumb = await readScrollbarThumb(reference);
await probe.hover();
expect(await readScrollbarThumb(probe)).toBe(referenceHoverThumb);
});
test("intentional hidden-scrollbar regions remain hidden", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => {
const probe = document.createElement("div");
const content = document.createElement("div");
probe.dataset.testid = "hidden-scrollbar-probe";
probe.className = "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
probe.style.cssText = "width:40px;height:40px;overflow:auto";
content.style.cssText = "width:80px;height:80px";
probe.append(content);
document.body.append(probe);
});
const hiddenProbe = page.getByTestId("hidden-scrollbar-probe");
const presentation = await hiddenProbe.evaluate((element) => ({
display: getComputedStyle(element, "::-webkit-scrollbar").display,
width: getComputedStyle(element).scrollbarWidth
}));
expect(presentation).toEqual({
display: "none",
width: "none"
});
});
async function readScrollbarPresentation(locator: Locator) {
return locator.evaluate((element) => {
const style = getComputedStyle(element);
const scrollbar = getComputedStyle(element, "::-webkit-scrollbar");
const track = getComputedStyle(element, "::-webkit-scrollbar-track");
const thumb = getComputedStyle(element, "::-webkit-scrollbar-thumb");
const corner = getComputedStyle(element, "::-webkit-scrollbar-corner");
return {
color: style.scrollbarColor,
cornerBackground: corner.backgroundColor,
height: scrollbar.height,
thumbBackground: thumb.backgroundColor,
thumbRadius: thumb.borderRadius,
trackBackground: track.backgroundColor,
trackRadius: track.borderRadius,
width: scrollbar.width,
widthMode: style.scrollbarWidth
};
});
}
async function readScrollbarThumb(locator: Locator) {
return locator.evaluate(
(element) => getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor
);
}
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-hit"))
);
await page.waitForTimeout(500);
await page.evaluate(() => {
const map = window.__waterNetworkMap;
document.documentElement.dataset.panelCameraMoveCount = "0";
map?.on("movestart", () => {
const currentCount = Number(
document.documentElement.dataset.panelCameraMoveCount ?? "0"
);
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
});
});
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await page.waitForTimeout(250);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.waitForTimeout(250);
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await page.waitForTimeout(250);
expect(
await page.evaluate(
() => document.documentElement.dataset.panelCameraMoveCount
)
).toBe("0");
});
+28 -2
View File
@@ -1,4 +1,4 @@
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
@@ -10,7 +10,33 @@ test("top bar uses compact controls on narrow mobile viewports", async ({ page }
);
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
await expect(page.getByRole("button", { name: /查看异常处置面板/ })).toBeVisible();
const alertButton = page.getByRole("button", { name: /查看异常处置面板/ });
const userButton = page.getByRole("button", { name: "打开用户菜单" });
await expect(alertButton).toBeVisible();
await expect(userButton).toBeVisible();
const [alertBox, userBox] = await Promise.all([
alertButton.boundingBox(),
userButton.boundingBox()
]);
expect(alertBox).not.toBeNull();
expect(userBox).not.toBeNull();
expect(alertBox?.width).toBe(40);
expect(alertBox?.height).toBe(40);
expect(userBox?.width).toBe(alertBox?.width);
expect(userBox?.height).toBe(alertBox?.height);
const [alertStyles, userStyles] = await Promise.all([
alertButton.evaluate((element) => {
const styles = getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, borderRadius: styles.borderRadius };
}),
userButton.evaluate((element) => {
const styles = getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, borderRadius: styles.borderRadius };
})
]);
expect(alertStyles).toEqual(userStyles);
});
test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => {
+301
View File
@@ -0,0 +1,301 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
test.describe("neutral blue mist workbench", () => {
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.emulateMedia({ reducedMotion: "reduce" });
await prepareDeterministicWorkbench(page);
});
test("desktop light basemap keeps both floating panels legible", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.64,
agentPanel: 0.82,
conditionPanel: 0.56,
control: 0.74
});
await expectLoadedChineseFont(page);
await expectBodyContrast(page);
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await selectBasemap(page, "影像");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await expectDesktopFloatingGeometry(page);
await expectAcrylicAlphas(page, {
navigation: 0.8,
agentPanel: 0.88,
conditionPanel: 0.72,
control: 0.86
});
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await openWorkbench(page);
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
await resizeHandle.focus();
await page.keyboard.press("End");
await expect(agentPanel).toHaveCSS("width", "720px");
await expectMapCorridor(agentPanel, conditionPanel, 680);
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "960px");
await expect(agentPanel).toHaveCSS("width", "628px");
await expectMapCorridor(agentPanel, conditionPanel, 256);
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("mobile launcher keeps Agent, map tools, and conditions visually balanced", async ({
page
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await openWorkbench(page);
await expect(page).toHaveScreenshot("workbench-mobile-launcher-light.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("mobile light basemap shows the Agent half sheet", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openWorkbench(page);
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
await expect(sheet).toHaveAttribute("data-snap", "half");
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
test("mobile satellite basemap shows the condition half sheet", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openWorkbench(page);
await selectBasemap(page, "影像");
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await expect(sheet).toHaveAttribute("data-snap", "half");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01
});
});
});
async function prepareDeterministicWorkbench(page: Page) {
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
});
});
await page.route("https://api.mapbox.com/**", async (route) => {
await route.fulfill({ contentType: "image/png", body: EMPTY_RASTER_TILE });
});
await page.route("https://visual-map.invalid/**", async (route) => {
await route.fulfill({
contentType: "application/vnd.mapbox-vector-tile",
body: Buffer.alloc(0)
});
});
await page.route("https://demotiles.maplibre.org/**", async (route) => {
await route.fulfill({ status: 204, body: "" });
});
await page.route("**/*.riv", async (route) => {
await route.abort();
});
}
async function openWorkbench(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.locator("main").evaluate((element) => {
element.setAttribute("data-visual-test", "true");
});
await page.addStyleTag({
content: `
[data-sonner-toaster] { display: none !important; }
[data-visual-test] .maplibregl-canvas { opacity: 0.28; }
[data-visual-test][data-basemap-tone="light"] .map-grid {
background:
radial-gradient(circle at 70% 32%, rgba(56, 120, 170, 0.18) 0 3px, transparent 4px),
linear-gradient(118deg, transparent 0 46%, rgba(65, 121, 160, 0.12) 46.2% 46.7%, transparent 47%),
linear-gradient(90deg, rgba(75, 124, 160, 0.06) 1px, transparent 1px),
linear-gradient(rgba(75, 124, 160, 0.06) 1px, transparent 1px),
#dce8ef;
background-size: auto, auto, 48px 48px, 48px 48px, auto;
}
[data-visual-test][data-basemap-tone="satellite"] .map-grid {
background:
radial-gradient(circle at 68% 30%, rgba(183, 207, 159, 0.24) 0 4px, transparent 5px),
linear-gradient(126deg, transparent 0 44%, rgba(178, 195, 153, 0.2) 44.2% 44.8%, transparent 45%),
repeating-linear-gradient(24deg, rgba(34, 67, 58, 0.18) 0 1px, transparent 1px 22px),
#526961;
}
`
});
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.waitForTimeout(350);
}
async function selectBasemap(page: Page, label: "浅色" | "影像") {
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.waitForTimeout(220);
}
async function expectDesktopFloatingGeometry(page: Page) {
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentComposer = agentPanel.locator(".agent-panel-composer");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(conditionPanel).toHaveCSS("width", "432px");
await expect(agentPanel).toHaveCSS("border-radius", "16px");
await expect(agentHeader).toHaveCSS("border-radius", "0px");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
const agentBox = await agentPanel.boundingBox();
const conditionBox = await conditionPanel.boundingBox();
expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(agentBox!.x).toBe(12);
expect(agentBox!.y).toBe(96);
expect(agentBox!.y + agentBox!.height).toBe(884);
expect(conditionBox!.x).toBe(944);
expect(conditionBox!.y).toBe(96);
}
async function expectMapCorridor(
agentPanel: Locator,
conditionPanel: Locator,
minimumWidth: number
) {
const agentBox = await agentPanel.boundingBox();
const conditionBox = await conditionPanel.boundingBox();
expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
}
async function expectLoadedChineseFont(page: Page) {
await expect
.poll(() =>
page.evaluate(() => document.fonts.check('16px "Noto Sans SC Variable"', "供水管网"))
)
.toBe(true);
}
async function expectAcrylicAlphas(
page: Page,
expected: {
navigation: number;
agentPanel: number;
conditionPanel: number;
control: number;
}
) {
const alphas = await page.evaluate(() => {
return {
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
agentPanel: alphaOf(
document.querySelector('aside[aria-label="Agent 命令面板"]')
),
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
control: alphaOf(document.querySelector(".acrylic-control"))
};
function alphaOf(element: Element | null) {
if (!element) {
return 1;
}
const color = getComputedStyle(element).backgroundColor;
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
return channels[3] ?? 1;
}
});
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
expect(alphas.control).toBeCloseTo(expected.control, 2);
}
async function expectBodyContrast(page: Page) {
const contrast = await page
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
.evaluate((element) => {
const foreground = parseColor(getComputedStyle(element).color);
const background = { r: 250, g: 252, b: 254 };
return contrastRatio(foreground, background);
function parseColor(color: string) {
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [0, 0, 0];
return { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 };
}
function contrastRatio(
first: { r: number; g: number; b: number },
second: { r: number; g: number; b: number }
) {
const lighter = Math.max(luminance(first), luminance(second));
const darker = Math.min(luminance(first), luminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
function luminance(color: { r: number; g: number; b: number }) {
const [r, g, b] = [color.r, color.g, color.b].map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
}
});
expect(contrast).toBeGreaterThanOrEqual(4.5);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

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