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.
@@ -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,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 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
||||
|
||||
@@ -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`,再通过对比度测试验证,不要在图层定义中
|
||||
写入新的颜色字面量。
|
||||
@@ -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>
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -73,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",
|
||||
|
||||
@@ -11,15 +11,6 @@ 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
|
||||
@@ -192,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
|
||||
@@ -457,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'}
|
||||
@@ -764,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
|
||||
@@ -835,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==}
|
||||
|
||||
@@ -877,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==}
|
||||
|
||||
@@ -1714,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==}
|
||||
|
||||
@@ -1855,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==}
|
||||
|
||||
@@ -2090,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==}
|
||||
|
||||
@@ -2108,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'}
|
||||
@@ -2156,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'}
|
||||
@@ -2211,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'}
|
||||
@@ -2481,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==}
|
||||
|
||||
@@ -2666,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'}
|
||||
@@ -2683,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==}
|
||||
|
||||
@@ -2974,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==}
|
||||
|
||||
@@ -3110,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==}
|
||||
|
||||
@@ -3361,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==}
|
||||
|
||||
@@ -3805,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'}
|
||||
@@ -3903,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==}
|
||||
|
||||
@@ -4120,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'}
|
||||
@@ -4491,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
|
||||
|
||||
@@ -4749,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
|
||||
@@ -4854,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
|
||||
@@ -4902,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': {}
|
||||
@@ -5550,6 +5237,7 @@ snapshots:
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
dependencies:
|
||||
@@ -5664,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':
|
||||
@@ -5829,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)':
|
||||
@@ -6009,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:
|
||||
@@ -6035,8 +5703,6 @@ snapshots:
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
|
||||
array-back@6.2.3: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -6073,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
|
||||
@@ -6128,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: {}
|
||||
@@ -6410,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):
|
||||
@@ -6623,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
|
||||
@@ -6635,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:
|
||||
@@ -6981,8 +6623,6 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bignum@0.0.3: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
@@ -7077,8 +6717,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
@@ -7594,8 +7232,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mjolnir.js@3.0.1: {}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
@@ -8099,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: {}
|
||||
@@ -8194,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:
|
||||
@@ -8377,8 +8006,6 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -28,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">
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
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";
|
||||
@@ -48,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 {
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentConnectionStatus,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
@@ -78,10 +80,7 @@ import type {
|
||||
AgentUiResult
|
||||
} from "../types";
|
||||
|
||||
export type AgentCommandPanelPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
presentation?: AgentCommandPanelPresentation;
|
||||
@@ -92,6 +91,7 @@ type AgentCommandPanelProps = {
|
||||
sessionHistoryLoading?: boolean;
|
||||
activeSessionId?: string | null;
|
||||
statusLabel?: string;
|
||||
connectionStatus?: AgentConnectionStatus;
|
||||
streaming?: boolean;
|
||||
messages?: AgentChatMessage[];
|
||||
streamRenderState?: AgentStreamRenderState;
|
||||
@@ -142,6 +142,7 @@ export function AgentCommandPanel({
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
connectionStatus = "connecting",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
streamRenderState = {},
|
||||
@@ -167,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,
|
||||
@@ -202,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]);
|
||||
@@ -221,155 +268,217 @@ 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",
|
||||
presentation === "desktop-dock"
|
||||
? "acrylic-panel rounded-r-2xl border-y border-r"
|
||||
: presentation === "desktop-floating"
|
||||
? "acrylic-panel rounded-2xl border"
|
||||
: "rounded-none border-0 bg-transparent",
|
||||
? "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">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
<Agent
|
||||
className={cn(
|
||||
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
|
||||
floatingPresentation && "gap-1 overflow-visible p-1"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-30 h-16 shrink-0">
|
||||
<header
|
||||
className={cn(
|
||||
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
|
||||
floatingPresentation && "agent-panel-integrated-header",
|
||||
presentation === "mobile-sheet" && "agent-panel-header-mobile",
|
||||
historyOpen && "agent-panel-header-expanded"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
</p>
|
||||
<StatusBadge
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
icon={
|
||||
<StatusDot
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{agentConnectionMetadata[connectionStatus].label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
getAgentConnectionClassName(statusLabel)
|
||||
)}
|
||||
/>
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref={historyTriggerRef}
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyPanelId}
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{presentation === "mobile-sheet" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭 Agent 面板"
|
||||
title="关闭 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{presentation === "mobile-sheet" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭 Agent 面板"
|
||||
title="关闭 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="group grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-500"
|
||||
>
|
||||
<span className="surface-control grid h-9 w-9 place-items-center rounded-full shadow-[inset_0_0_0_1px_rgba(148,163,184,0.26),0_4px_12px_rgba(15,33,55,0.10)] transition-[color,background-color,scale] group-active:scale-95 [@media(hover:hover)]:group-hover:bg-blue-50 [@media(hover:hover)]:group-hover:text-blue-700">
|
||||
<X size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
ref={historyPanelRef}
|
||||
key="history"
|
||||
id={historyPanelId}
|
||||
role="region"
|
||||
aria-label="Agent 历史记录"
|
||||
className="agent-panel-floating-acrylic agent-panel-history-extension"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{historyPanel}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{floatingPresentation ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
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 ? (
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
aria-hidden={historyOpen}
|
||||
className={cn(
|
||||
"space-y-2 px-3 pb-3",
|
||||
historyOpen && "invisible pointer-events-none"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<Conversation
|
||||
className={cn(
|
||||
"agent-panel-conversation min-h-0",
|
||||
floatingPresentation && "agent-panel-conversation-canvas"
|
||||
)}
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
||||
className={cn(
|
||||
"gap-4 p-3",
|
||||
messages.length === 0 && "min-h-full",
|
||||
floatingPresentation && "agent-panel-conversation-content",
|
||||
floatingPresentation &&
|
||||
hasConversation &&
|
||||
"agent-panel-conversation-content-has-brief",
|
||||
floatingPresentation &&
|
||||
!hasConversation &&
|
||||
"agent-panel-conversation-content-empty"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
@@ -409,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"
|
||||
@@ -436,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) {
|
||||
@@ -448,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>
|
||||
@@ -460,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}
|
||||
@@ -522,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={
|
||||
@@ -564,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] }
|
||||
@@ -623,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="权限批准模式"
|
||||
>
|
||||
@@ -664,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>
|
||||
@@ -684,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(() => {
|
||||
@@ -718,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;
|
||||
@@ -738,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") {
|
||||
@@ -771,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 模型"
|
||||
>
|
||||
@@ -826,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} />;
|
||||
@@ -911,7 +1068,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"surface-well grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.88_0.02_260)]",
|
||||
"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]"
|
||||
)}
|
||||
>
|
||||
@@ -921,7 +1078,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className={cn(
|
||||
"text-balance font-semibold text-slate-900",
|
||||
"text-balance font-semibold text-slate-950",
|
||||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||
)}
|
||||
>
|
||||
@@ -932,7 +1089,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-pretty text-left text-sm leading-6 text-slate-500",
|
||||
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||||
compact ? "mt-3" : "mt-5"
|
||||
)}
|
||||
>
|
||||
@@ -942,7 +1099,7 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
<ul
|
||||
className={cn(
|
||||
"surface-well grid grid-cols-2 overflow-hidden rounded-xl",
|
||||
"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 分析能力"
|
||||
@@ -951,15 +1108,15 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
<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>
|
||||
@@ -1021,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">
|
||||
@@ -1209,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}
|
||||
>
|
||||
@@ -1311,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>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
||||
<motion.div
|
||||
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||
active && !editing
|
||||
? "agent-history-item-active border border-blue-200"
|
||||
: "border border-transparent"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
||||
<motion.div
|
||||
key="confirm-delete"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<span
|
||||
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||
title={session.title}
|
||||
>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 rounded-lg bg-white text-xs"
|
||||
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
@@ -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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<span>执行进度</span>
|
||||
<span className="flex-1" />
|
||||
<span className="shrink-0 font-normal text-slate-400">
|
||||
{expanded
|
||||
? `全部 ${progress.length} 条`
|
||||
: running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: `共 ${progress.length} 条`}
|
||||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
|
||||
{running ? (
|
||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||
) : null}
|
||||
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||||
<span className="font-mono tabular-nums text-slate-600">
|
||||
{expanded || !running
|
||||
? progress.length
|
||||
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||||
<span className="px-0.5 text-slate-300">/</span>
|
||||
{progress.length}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
|
||||
expanded && "text-blue-500"
|
||||
)}
|
||||
aria-controls={listId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
|
||||
data-state={expanded ? "open" : "closed"}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
id={listId}
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: agentEase }}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
||||
progressStatusMeta[item.status].dotClassName
|
||||
)}
|
||||
animate={
|
||||
item.status === "running"
|
||||
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
||||
: item.status === "error"
|
||||
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
||||
: { opacity: 1, scale: 1 }
|
||||
}
|
||||
transition={
|
||||
item.status === "running"
|
||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
||||
: item.status === "error"
|
||||
? { duration: 0.28, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
<StatusDot
|
||||
tone={progressStatusMeta[item.status].tone}
|
||||
activity={progressStatusMeta[item.status].activity}
|
||||
size="md"
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
key={item.status}
|
||||
data-progress-status={item.status}
|
||||
className={cn(
|
||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
||||
progressStatusMeta[item.status].className
|
||||
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||
item.status === "running"
|
||||
? "text-blue-600"
|
||||
: [
|
||||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||
"text-[var(--status-foreground)]"
|
||||
]
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
item.status === "error"
|
||||
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||
: { opacity: 1, x: 0 }
|
||||
}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={
|
||||
item.status === "error"
|
||||
? { duration: 0.24, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
transition={{ duration: 0.14 }}
|
||||
>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</motion.span>
|
||||
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
|
||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
||||
todoStatusMeta[todo.status].dotClassName
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
|
||||
</SemanticStatusIcon>
|
||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
||||
<StatusBadge
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
>
|
||||
{todoStatusMeta[todo.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -376,7 +385,7 @@ function PermissionList({
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
|
||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
@@ -424,18 +433,22 @@ function PermissionRequestCard({
|
||||
{target}
|
||||
</p>
|
||||
{permission.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||
<StatusBadge
|
||||
tone={permissionStatusMeta[permission.status].tone}
|
||||
activity={permissionStatusMeta[permission.status].activity}
|
||||
className="self-start"
|
||||
>
|
||||
{permissionStatusMeta[permission.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="permission-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
className="mt-2 flex flex-wrap items-center gap-2"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -445,7 +458,7 @@ function PermissionRequestCard({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("once")}
|
||||
>
|
||||
@@ -457,7 +470,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("always")}
|
||||
>
|
||||
@@ -469,7 +482,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
||||
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("reject")}
|
||||
>
|
||||
@@ -547,9 +560,12 @@ function QuestionRequestCard({
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
||||
<StatusBadge
|
||||
tone={questionStatusMeta[request.status].tone}
|
||||
activity={questionStatusMeta[request.status].activity}
|
||||
>
|
||||
{questionStatusMeta[request.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -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)} />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
||||
<AgentBriefMetric
|
||||
icon={<Radio size={13} aria-hidden="true" />}
|
||||
label="证据"
|
||||
value={brief.evidence}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<Route size={13} aria-hidden="true" />}
|
||||
label="计划"
|
||||
value={brief.plan}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<ClipboardCheck size={13} aria-hidden="true" />}
|
||||
label="确认"
|
||||
value={brief.confirmation}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||
>
|
||||
<MapPinned size={14} aria-hidden="true" />
|
||||
预览影响
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
<Crosshair size={14} aria-hidden="true" />
|
||||
检查证据
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
function AgentBriefMetric({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
</dt>
|
||||
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
|
||||
);
|
||||
}
|
||||
|
||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
||||
function createOperationalBrief(
|
||||
messages: AgentChatMessage[],
|
||||
statusLabel: string,
|
||||
streaming: boolean
|
||||
) {
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user" && message.content.trim());
|
||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
||||
const latestProgress = progressItems.at(-1);
|
||||
const activeTodo =
|
||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||
const pendingConfirmations =
|
||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
||||
const pendingConfirmations = countPendingAgentInputs(messages);
|
||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const plan =
|
||||
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
||||
const stateLabel =
|
||||
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusTone: StatusTone =
|
||||
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
|
||||
|
||||
return {
|
||||
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
|
||||
plan,
|
||||
confirmation,
|
||||
stateLabel,
|
||||
statusDotClassName,
|
||||
statusTone,
|
||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
@@ -282,7 +283,7 @@ function TrustedPanelShell({
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
||||
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -56,6 +57,7 @@ export type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentConnectionStatus,
|
||||
AgentInteractionToolRef,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
describe("Agent message state", () => {
|
||||
it("counts pending and failed operator inputs from one shared rule", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
permissions: [
|
||||
{ status: "pending" },
|
||||
{ status: "approved_once" }
|
||||
],
|
||||
questions: [
|
||||
{ status: "error" },
|
||||
{ status: "answered" }
|
||||
]
|
||||
}
|
||||
] as AgentChatMessage[];
|
||||
|
||||
expect(countPendingAgentInputs(messages)).toBe(2);
|
||||
expect(hasPendingAgentInput(messages)).toBe(true);
|
||||
expect(hasPendingAgentInput([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
|
||||
return messages.reduce((total, message) => {
|
||||
const pendingPermissions =
|
||||
message.permissions?.filter(
|
||||
(permission) => permission.status === "pending" || permission.status === "error"
|
||||
).length ?? 0;
|
||||
const pendingQuestions =
|
||||
message.questions?.filter(
|
||||
(question) => question.status === "pending" || question.status === "error"
|
||||
).length ?? 0;
|
||||
return total + pendingPermissions + pendingQuestions;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
|
||||
return countPendingAgentInputs(messages) > 0;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "glass-transient text-slate-900",
|
||||
success: "glass-transient text-slate-900",
|
||||
warning: "glass-transient text-slate-900",
|
||||
error: "glass-transient text-slate-900",
|
||||
loading: "glass-transient 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,
|
||||
@@ -79,15 +60,10 @@ 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>
|
||||
);
|
||||
@@ -101,20 +77,14 @@ function MapNoticeIcon({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,16 +112,15 @@ function MapNoticeBody({
|
||||
|
||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function MapToaster() {
|
||||
classNames: {
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
||||
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -44,10 +44,10 @@ export function MapToolbar({
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto p-[3px]",
|
||||
"pointer-events-auto p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
||||
isVertical ? "flex w-12 flex-col" : "inline-flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -67,19 +67,18 @@ export function MapToolbar({
|
||||
disabled={tool.disabled}
|
||||
onClick={tool.onClick}
|
||||
className={cn(
|
||||
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
|
||||
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
tool.active
|
||||
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
|
||||
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||
<span className="sr-only">{tool.label}</span>
|
||||
{tool.badge ? (
|
||||
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
<span className="status-tone-danger absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-[var(--status-mark)] px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
{tool.badge}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -37,13 +37,13 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
role="group"
|
||||
aria-label="地图缩放"
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-[46px] flex-col gap-2",
|
||||
"pointer-events-auto flex w-12 flex-col gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col p-[3px]",
|
||||
"flex flex-col p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -65,7 +65,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"p-[3px]",
|
||||
"p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -99,13 +99,12 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
||||
"grid h-10 w-10 place-items-center border border-transparent",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
"bg-transparent text-slate-700",
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
disabled &&
|
||||
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
|
||||
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
|
||||
@@ -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 (firstNumeric) setProperty(firstNumeric[0]);
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
selectStyleGroup(nextGroupId);
|
||||
const allowedProperties = new Set(
|
||||
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
|
||||
);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
|
||||
([name]) => allowedProperties.has(name)
|
||||
);
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
function applyLayerStyle() {
|
||||
let patch: LayerGroupStylePatch;
|
||||
if (geometry === "line") {
|
||||
patch = {
|
||||
@@ -103,15 +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>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
|
||||
{flowSummary.reverse} · 停止 {flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后自动显示全网 mock 流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
|
||||
<Field label="数值属性">
|
||||
{numericProperties.length ? (
|
||||
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
|
||||
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
|
||||
</select>
|
||||
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
|
||||
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
|
||||
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
|
||||
<PanelSection
|
||||
title="样式编辑"
|
||||
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
|
||||
>
|
||||
<Field label="业务图层组">
|
||||
<div className="relative">
|
||||
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
|
||||
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={groupId}
|
||||
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
|
||||
className={cn(inputClassName, "appearance-none pr-8")}
|
||||
>
|
||||
{STYLE_GROUP_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GROUP_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
|
||||
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<Field label="线型">
|
||||
<select
|
||||
value={draft.dash}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, dash: event.target.value }))
|
||||
}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="original">原始</option>
|
||||
<option value="solid">实线</option>
|
||||
<option value="dashed">虚线</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
|
||||
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
|
||||
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
|
||||
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
|
||||
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
|
||||
作用于当前图层所有可见 Feature,自动避让重叠。
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
|
||||
MVT 原生
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<Field label="数值属性">
|
||||
<select
|
||||
value={property}
|
||||
onChange={(event) => setProperty(event.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
{LABEL_PRESETS[groupId].options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/70 p-3">
|
||||
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
重置全部
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
|
||||
function PanelSection({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
|
||||
function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
|
||||
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
|
||||
function ColorField({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-600">{value}</span>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
function RangeField({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={`${label} · ${value}`}>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-6 w-full accent-blue-600"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
|
||||
@@ -149,7 +149,7 @@ export function MobileWorkbenchSheet({
|
||||
)}
|
||||
style={{ height: dragHeight ?? restingHeight }}
|
||||
>
|
||||
<div className="flex h-7 shrink-0 items-center">
|
||||
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
|
||||
@@ -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;
|
||||
@@ -106,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,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 }}
|
||||
@@ -199,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}
|
||||
/>
|
||||
@@ -222,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}
|
||||
/>
|
||||
@@ -332,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
NEUTRAL_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -344,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,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" />}
|
||||
@@ -544,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">
|
||||
@@ -646,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,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}>
|
||||
@@ -710,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}>
|
||||
@@ -721,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 ? (
|
||||
@@ -736,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,
|
||||
@@ -807,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) {
|
||||
@@ -932,7 +906,6 @@ function RunningWorkflowPanel({
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={Loader2}
|
||||
title={workflow.title}
|
||||
badge="执行中"
|
||||
badgeTone="active"
|
||||
@@ -988,7 +961,7 @@ function TaskExecutionPanel({
|
||||
badgeTone = "pending",
|
||||
children
|
||||
}: {
|
||||
icon: typeof Bot;
|
||||
icon?: typeof Bot;
|
||||
title: string;
|
||||
badge: string;
|
||||
summary?: string;
|
||||
@@ -1001,10 +974,17 @@ function TaskExecutionPanel({
|
||||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||||
{Icon ? (
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{title}</span>
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={getTaskExecutionBadgeTone(badgeTone)}
|
||||
activity={badgeTone === "active" ? "live" : "static"}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||||
{meta?.length ? (
|
||||
@@ -1043,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))}>
|
||||
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
|
||||
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
{step.meta?.length ? (
|
||||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||||
|
||||
@@ -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,6 +5,7 @@ import {
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionItemStatus,
|
||||
@@ -23,14 +24,23 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
awaiting_reply: "border-orange-100 bg-orange-50 text-orange-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 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,
|
||||
@@ -41,14 +51,17 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
awaiting_reply: "bg-orange-50 text-orange-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 workOrderStageActivities = {
|
||||
dispatch: "static",
|
||||
execution: "loading",
|
||||
reply: "static"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
|
||||
"running",
|
||||
@@ -165,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionItemStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionItemStatus) {
|
||||
return statusIconClassNames[status];
|
||||
export function getStatusTone(status: ScheduledConditionItemStatus) {
|
||||
return statusTones[status];
|
||||
}
|
||||
|
||||
export function getStatusActivity(status: ScheduledConditionItemStatus) {
|
||||
return statusActivities[status];
|
||||
}
|
||||
|
||||
@@ -21,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,
|
||||
@@ -28,8 +29,12 @@ import {
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusActivity,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
getStatusTone,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
@@ -286,7 +291,12 @@ export function ScheduledConditionFeed({
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? "max-h-[calc(100dvh-8rem)] rounded-2xl"
|
||||
? 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"
|
||||
@@ -334,11 +344,9 @@ export function ScheduledConditionFeed({
|
||||
aria-label="关闭工况任务"
|
||||
title="关闭工况任务"
|
||||
onClick={onClose}
|
||||
className="group grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-500"
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<span className="surface-control grid h-9 w-9 place-items-center rounded-full shadow-[inset_0_0_0_1px_rgba(148,163,184,0.26),0_4px_12px_rgba(15,33,55,0.10)] transition-[color,background-color,scale] group-active:scale-95 [@media(hover:hover)]:group-hover:bg-blue-50 [@media(hover:hover)]:group-hover:text-blue-700">
|
||||
<X size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -349,19 +357,19 @@ export function ScheduledConditionFeed({
|
||||
mobileSheet
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded"
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
"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"
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
@@ -459,7 +467,7 @@ function ConditionTimelinePanel({
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-[calc(100dvh-8.75rem)] max-h-[calc(100dvh-8.75rem)]"
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -486,7 +494,7 @@ function ConditionTimelinePanel({
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<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}
|
||||
@@ -583,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" />
|
||||
重置筛选
|
||||
@@ -722,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="工况详情加载中"
|
||||
>
|
||||
@@ -760,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">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -830,8 +845,15 @@ function ConditionTimelineRow({
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
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
|
||||
@@ -855,22 +877,18 @@ function ConditionTimelineRow({
|
||||
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(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import {
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
@@ -13,6 +10,7 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
conditionExpanded: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
@@ -25,6 +23,7 @@ export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
conditionExpanded,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
@@ -32,8 +31,8 @@ export function WorkbenchAgentPanels({
|
||||
onExpandPanel,
|
||||
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(() => {
|
||||
@@ -43,8 +42,8 @@ export function WorkbenchAgentPanels({
|
||||
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-[620px] 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 ? (
|
||||
<>
|
||||
@@ -57,7 +56,7 @@ export function WorkbenchAgentPanels({
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth)}
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -138,26 +138,18 @@ export function WorkbenchTopBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
@@ -210,15 +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"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -232,15 +221,12 @@ function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<span className={headerControlIconClassName}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
|
||||
@@ -29,6 +29,23 @@ describe("createOperationalScheduledConditions", () => {
|
||||
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(
|
||||
|
||||
@@ -438,7 +438,7 @@ function createWorkOrderStages(
|
||||
},
|
||||
{
|
||||
id: "execution" as const,
|
||||
title: "执行",
|
||||
title: "执行中",
|
||||
status:
|
||||
phase === "dispatch"
|
||||
? ("pending" as const)
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
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);
|
||||
@@ -29,20 +29,24 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth);
|
||||
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
setCommittedWidth(nextWidth);
|
||||
}, [viewportWidth]);
|
||||
}, [conditionExpanded, viewportWidth]);
|
||||
|
||||
const resize = useCallback((clientX: number) => {
|
||||
const nextWidth = clampAgentPanelWidth(
|
||||
clientX - resizeLeftRef.current,
|
||||
window.innerWidth
|
||||
);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
}, []);
|
||||
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>) => {
|
||||
@@ -78,29 +82,32 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
[resize]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth);
|
||||
widthRef.current = clampedWidth;
|
||||
setWidth(clampedWidth);
|
||||
setCommittedWidth(clampedWidth);
|
||||
}, []);
|
||||
event.preventDefault();
|
||||
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
|
||||
widthRef.current = clampedWidth;
|
||||
setWidth(clampedWidth);
|
||||
setCommittedWidth(clampedWidth);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
return {
|
||||
committedWidth,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -79,7 +75,9 @@ 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"
|
||||
};
|
||||
@@ -121,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"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[0],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[1],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
|
||||
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
|
||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type === "symbol")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
@@ -160,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"
|
||||
@@ -190,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);
|
||||
}
|
||||
@@ -239,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) {
|
||||
@@ -250,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
|
||||
@@ -296,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" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelDefaultWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchCameraPadding,
|
||||
@@ -21,13 +22,13 @@ describe("workbench basemap surface tone", () => {
|
||||
describe("workbench floating panels", () => {
|
||||
it("uses bounded desktop and wide panel widths", () => {
|
||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||
agentWidth: 460,
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48
|
||||
});
|
||||
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
|
||||
agentWidth: 500,
|
||||
agentWidth: 540,
|
||||
conditionExpandedWidth: 960
|
||||
});
|
||||
});
|
||||
@@ -39,7 +40,7 @@ describe("workbench floating panels", () => {
|
||||
conditionOpen: true,
|
||||
conditionExpanded: false
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 484 });
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 524 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
@@ -47,7 +48,7 @@ describe("workbench floating panels", () => {
|
||||
conditionOpen: true,
|
||||
conditionExpanded: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 484 });
|
||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
||||
});
|
||||
|
||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||
@@ -65,12 +66,20 @@ describe("workbench floating panels", () => {
|
||||
agentWidth: 900,
|
||||
conditionOpen: false
|
||||
}).left
|
||||
).toBe(644);
|
||||
).toBe(744);
|
||||
});
|
||||
|
||||
it("reserves room for the compact condition panel on narrow desktops", () => {
|
||||
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(1440)).toBe(620);
|
||||
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,17 +4,20 @@ export const WORKBENCH_LAYOUT = {
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
maxAgentWidth: 620,
|
||||
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,
|
||||
@@ -26,28 +29,48 @@ export const WORKBENCH_LAYOUT = {
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
|
||||
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
export function clampAgentPanelWidth(
|
||||
width: number,
|
||||
viewportWidth: number,
|
||||
conditionExpanded = false
|
||||
) {
|
||||
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
|
||||
return Math.round(
|
||||
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth))
|
||||
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentPanelMaxWidth(viewportWidth: number) {
|
||||
export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpanded = false) {
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const widthWithCompactCondition =
|
||||
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||
return layout.agentWidth;
|
||||
}
|
||||
|
||||
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
|
||||
const collisionSafeWidth =
|
||||
viewportWidth -
|
||||
12 -
|
||||
WORKBENCH_LAYOUT.agentPanelLeftInset -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
layout.conditionWidth -
|
||||
conditionWidth -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
layout.toolbarWidth;
|
||||
|
||||
return Math.max(
|
||||
layout.agentWidth,
|
||||
Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithCompactCondition)
|
||||
);
|
||||
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 = {
|
||||
@@ -93,8 +116,15 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen
|
||||
? panels.agentWidth === undefined
|
||||
? layout.agentWidth
|
||||
: clampAgentPanelWidth(panels.agentWidth, viewportWidth)
|
||||
? 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
|
||||
|
||||
@@ -814,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,
|
||||
@@ -886,6 +887,7 @@ export function MapWorkbenchPage() {
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
@@ -982,7 +984,7 @@ export function MapWorkbenchPage() {
|
||||
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 active:scale-95"
|
||||
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>
|
||||
@@ -996,7 +998,7 @@ export function MapWorkbenchPage() {
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700 active:scale-95"
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 484 }
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 524 }
|
||||
},
|
||||
{
|
||||
agentOpen: false,
|
||||
@@ -33,7 +33,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 484 }
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 524 }
|
||||
}
|
||||
])(
|
||||
"covers agent=$agentOpen and condition=$conditionOpen",
|
||||
@@ -47,7 +47,7 @@ describe("workbench camera padding", () => {
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 32,
|
||||
left: 644
|
||||
left: 540
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./map-colors";
|
||||
import { createBaseStyle } from "./sources";
|
||||
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
|
||||
const mapBackgrounds = [
|
||||
MAP_STYLE_TOKENS.canvas.primary,
|
||||
MAP_STYLE_TOKENS.canvas.secondary,
|
||||
@@ -47,18 +47,35 @@ describe("map color contrast", () => {
|
||||
});
|
||||
|
||||
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||
sourceColors.forEach(() => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps operational states above 3:1 on every map background", () => {
|
||||
const operationalColors = [
|
||||
MAP_STYLE_TOKENS.state.success,
|
||||
MAP_STYLE_TOKENS.state.risk,
|
||||
MAP_STYLE_TOKENS.state.incident,
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
MAP_STYLE_TOKENS.state.agent
|
||||
];
|
||||
|
||||
operationalColors.forEach((stateColor) => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
getContrastRatio(stateColor, background),
|
||||
createCoolorsContrastUrl(stateColor, background)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,99 +1,437 @@
|
||||
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
import type {
|
||||
ExpressionSpecification,
|
||||
Map as MapLibreMap,
|
||||
StyleImageInterface
|
||||
} from "maplibre-gl";
|
||||
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
|
||||
export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward";
|
||||
export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse";
|
||||
|
||||
const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward";
|
||||
const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse";
|
||||
const FLOW_STATE_DIRECTION = "flowDirection";
|
||||
const FLOW_PATTERN_WIDTH = 64;
|
||||
const FLOW_PATTERN_HEIGHT = 8;
|
||||
const FLOW_PATTERN_DURATION_MS = 1_200;
|
||||
|
||||
export type PipeFlowCalculation = {
|
||||
pipeId: string | number;
|
||||
velocity: number;
|
||||
};
|
||||
|
||||
export type PipeFlowDirection = -1 | 0 | 1;
|
||||
|
||||
export type PipeFlowSummary = {
|
||||
total: number;
|
||||
forward: number;
|
||||
reverse: number;
|
||||
stopped: number;
|
||||
invalid: number;
|
||||
};
|
||||
|
||||
type NormalizedPipeFlowCalculation = {
|
||||
pipeId: string | number;
|
||||
direction: PipeFlowDirection;
|
||||
};
|
||||
|
||||
export type FlowOverlayState = {
|
||||
data: FlowFeature[] | null;
|
||||
animationFrameId: number | null;
|
||||
overlay: MapboxOverlay | null;
|
||||
calculations: Map<string, NormalizedPipeFlowCalculation>;
|
||||
revision: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function createFlowOverlayState(): FlowOverlayState {
|
||||
return { data: null, animationFrameId: null, overlay: null };
|
||||
return {
|
||||
calculations: new Map(),
|
||||
revision: 0,
|
||||
visible: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function showFlowOverlay(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
loadData: () => Promise<FeatureCollection>,
|
||||
_state: FlowOverlayState,
|
||||
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
if (!state.data) {
|
||||
const collection = await loadData();
|
||||
state.data = collection.features.filter(isFlowFeature);
|
||||
}
|
||||
ensureFlowImages(map, reducedMotion);
|
||||
ensureFlowLayers(map);
|
||||
setFlowMockVisibility(map, _state.calculations.size === 0);
|
||||
_state.visible = true;
|
||||
setFlowLayerVisibility(map, "visible");
|
||||
}
|
||||
|
||||
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
|
||||
import("@deck.gl/mapbox"),
|
||||
import("@deck.gl/layers"),
|
||||
import("./flow-path-extension")
|
||||
]);
|
||||
const extension = new FlowPathExtension();
|
||||
let renderingError: Error | null = null;
|
||||
const render = (phase: number) => {
|
||||
const layer = new PathLayer<FlowFeature>({
|
||||
id: "workbench-network-flow",
|
||||
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
|
||||
data: state.data ?? [],
|
||||
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
|
||||
getColor: [15, 118, 110, 210],
|
||||
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
|
||||
widthUnits: "pixels",
|
||||
widthMinPixels: 1,
|
||||
widthMaxPixels: 5,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
pickable: false,
|
||||
parameters: { depthTest: false },
|
||||
extensions: [extension],
|
||||
flowPhase: phase
|
||||
} as never);
|
||||
state.overlay?.setProps({
|
||||
layers: [layer],
|
||||
onError: (error) => {
|
||||
renderingError = error;
|
||||
}
|
||||
});
|
||||
export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) {
|
||||
_state.visible = false;
|
||||
setFlowLayerVisibility(map, "none");
|
||||
}
|
||||
|
||||
export async function replacePipeFlowCalculations(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
records: readonly PipeFlowCalculation[]
|
||||
): Promise<PipeFlowSummary> {
|
||||
const revision = ++state.revision;
|
||||
const { calculations: next, invalid } = normalizePipeFlowCalculations(records);
|
||||
const changes: FlowStateChange[] = [];
|
||||
setFlowMockVisibility(map, false);
|
||||
|
||||
state.calculations.forEach((_, key) => {
|
||||
if (!next.has(key)) changes.push({ type: "remove", key });
|
||||
});
|
||||
next.forEach((calculation, key) => {
|
||||
const previous = state.calculations.get(key);
|
||||
if (
|
||||
previous?.direction !== calculation.direction
|
||||
) {
|
||||
changes.push({ type: "set", key, calculation });
|
||||
}
|
||||
});
|
||||
|
||||
if (state.visible && changes.length > 0) {
|
||||
setFlowLayerVisibility(map, "none");
|
||||
}
|
||||
await applyFlowStateChanges(map, state, changes, revision);
|
||||
if (state.visible && revision === state.revision) {
|
||||
setFlowLayerVisibility(map, "visible");
|
||||
}
|
||||
return summarizePipeFlowCalculations(state.calculations.values(), invalid);
|
||||
}
|
||||
|
||||
export function updatePipeFlowCalculation(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
record: PipeFlowCalculation
|
||||
): PipeFlowSummary {
|
||||
const calculation = normalizePipeFlowCalculation(record);
|
||||
if (!calculation) throw new Error("INVALID_FLOW_CALCULATION");
|
||||
state.revision += 1;
|
||||
const key = String(calculation.pipeId);
|
||||
const previous = state.calculations.get(key);
|
||||
if (
|
||||
previous?.direction !== calculation.direction
|
||||
) {
|
||||
setPipeFlowState(map, calculation);
|
||||
state.calculations.set(key, calculation);
|
||||
}
|
||||
if (state.visible) setFlowLayerVisibility(map, "visible");
|
||||
return summarizePipeFlowCalculations(state.calculations.values(), 0);
|
||||
}
|
||||
|
||||
export function clearPipeFlowCalculations(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState
|
||||
) {
|
||||
return replacePipeFlowCalculations(map, state, []).then((summary) => {
|
||||
if (state.visible) {
|
||||
setFlowMockVisibility(map, true);
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
export function getPipeFlowDirection(velocity: number): PipeFlowDirection {
|
||||
if (!Number.isFinite(velocity) || velocity === 0) return 0;
|
||||
return velocity < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
type FlowStateChange =
|
||||
| { type: "remove"; key: string }
|
||||
| {
|
||||
type: "set";
|
||||
key: string;
|
||||
calculation: NormalizedPipeFlowCalculation;
|
||||
};
|
||||
|
||||
if (!state.overlay) {
|
||||
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
|
||||
map.addControl(overlay);
|
||||
state.overlay = overlay;
|
||||
}
|
||||
function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) {
|
||||
const calculations = new Map<string, NormalizedPipeFlowCalculation>();
|
||||
let invalid = 0;
|
||||
records.forEach((record) => {
|
||||
const normalized = normalizePipeFlowCalculation(record);
|
||||
if (!normalized) {
|
||||
invalid += 1;
|
||||
return;
|
||||
}
|
||||
calculations.set(String(normalized.pipeId), normalized);
|
||||
});
|
||||
return { calculations, invalid };
|
||||
}
|
||||
|
||||
stopFlowAnimation(state);
|
||||
render(0.2);
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
if (renderingError) throw renderingError;
|
||||
if (!reducedMotion) {
|
||||
const startedAt = performance.now();
|
||||
const tick = (now: number) => {
|
||||
render(((now - startedAt) / 1800) % 1);
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
function normalizePipeFlowCalculation(
|
||||
record: PipeFlowCalculation
|
||||
): NormalizedPipeFlowCalculation | null {
|
||||
if (
|
||||
(typeof record.pipeId !== "string" && typeof record.pipeId !== "number")
|
||||
|| String(record.pipeId).trim().length === 0
|
||||
|| !Number.isFinite(record.velocity)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pipeId: record.pipeId,
|
||||
direction: getPipeFlowDirection(record.velocity)
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePipeFlowCalculations(
|
||||
calculations: Iterable<NormalizedPipeFlowCalculation>,
|
||||
invalid: number
|
||||
): PipeFlowSummary {
|
||||
const summary: PipeFlowSummary = {
|
||||
total: 0,
|
||||
forward: 0,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid
|
||||
};
|
||||
for (const calculation of calculations) {
|
||||
summary.total += 1;
|
||||
if (calculation.direction > 0) summary.forward += 1;
|
||||
else if (calculation.direction < 0) summary.reverse += 1;
|
||||
else summary.stopped += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function setPipeFlowState(
|
||||
map: MapLibreMap,
|
||||
calculation: NormalizedPipeFlowCalculation
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id: calculation.pipeId
|
||||
},
|
||||
{
|
||||
[FLOW_STATE_DIRECTION]: calculation.direction
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function applyFlowStateChanges(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
changes: readonly FlowStateChange[],
|
||||
revision: number
|
||||
) {
|
||||
const batchSize = 2_000;
|
||||
let offset = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const applyBatch = () => {
|
||||
if (revision !== state.revision) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const end = Math.min(offset + batchSize, changes.length);
|
||||
for (; offset < end; offset += 1) {
|
||||
const change = changes[offset];
|
||||
if (change.type === "remove") {
|
||||
removePipeFlowState(map, change.key);
|
||||
state.calculations.delete(change.key);
|
||||
} else {
|
||||
setPipeFlowState(map, change.calculation);
|
||||
state.calculations.set(change.key, change.calculation);
|
||||
}
|
||||
}
|
||||
if (offset >= changes.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(applyBatch);
|
||||
};
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
applyBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function removePipeFlowState(map: MapLibreMap, id: string | number) {
|
||||
const target = {
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id
|
||||
};
|
||||
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
|
||||
}
|
||||
|
||||
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
|
||||
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_FORWARD_IMAGE_ID,
|
||||
createFlowPatternImage(1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_REVERSE_IMAGE_ID,
|
||||
createFlowPatternImage(-1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
|
||||
stopFlowAnimation(state);
|
||||
if (state.overlay) {
|
||||
map.removeControl(state.overlay as never);
|
||||
state.overlay = null;
|
||||
}
|
||||
function createFlowPatternImage(
|
||||
direction: -1 | 1,
|
||||
reducedMotion: boolean
|
||||
): StyleImageInterface {
|
||||
let map: MapLibreMap | null = null;
|
||||
let lastOffset = Number.NaN;
|
||||
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
|
||||
|
||||
const draw = (offset: number) => {
|
||||
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
|
||||
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
|
||||
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
|
||||
const wrapped = (
|
||||
x - offset + FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
const pulse = wrapped < 18
|
||||
? Math.sin((Math.PI * wrapped) / 18)
|
||||
: 0;
|
||||
const alpha = Math.round(255 * pulse * edgeFade);
|
||||
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
|
||||
data[index] = 94;
|
||||
data[index + 1] = 234;
|
||||
data[index + 2] = 212;
|
||||
data[index + 3] = alpha;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
|
||||
|
||||
return {
|
||||
width: FLOW_PATTERN_WIDTH,
|
||||
height: FLOW_PATTERN_HEIGHT,
|
||||
data,
|
||||
onAdd(nextMap) {
|
||||
map = nextMap;
|
||||
},
|
||||
render() {
|
||||
if (reducedMotion) return false;
|
||||
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
|
||||
/ FLOW_PATTERN_DURATION_MS;
|
||||
const offset = Math.floor(
|
||||
progress * FLOW_PATTERN_WIDTH * direction
|
||||
+ FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
if (offset === lastOffset) {
|
||||
map?.triggerRepaint();
|
||||
return false;
|
||||
}
|
||||
lastOffset = offset;
|
||||
draw(offset);
|
||||
map?.triggerRepaint();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function stopFlowAnimation(state: FlowOverlayState) {
|
||||
if (state.animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(state.animationFrameId);
|
||||
state.animationFrameId = null;
|
||||
const mockDirectionExpression: ExpressionSpecification = [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
["%", ["to-number", ["id"], 0], 2],
|
||||
0
|
||||
],
|
||||
1,
|
||||
-1
|
||||
];
|
||||
|
||||
const directionExpression = (
|
||||
direction: -1 | 1,
|
||||
visibleOpacity: number,
|
||||
mockVisible = false
|
||||
): ExpressionSpecification => [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
[
|
||||
"coalesce",
|
||||
["feature-state", FLOW_STATE_DIRECTION],
|
||||
mockVisible ? mockDirectionExpression : 0
|
||||
],
|
||||
direction
|
||||
],
|
||||
visibleOpacity,
|
||||
0
|
||||
];
|
||||
|
||||
function ensureFlowLayers(map: MapLibreMap) {
|
||||
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_FORWARD_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_REVERSE_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_REVERSE_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(-1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function isFlowFeature(feature: Feature): feature is FlowFeature {
|
||||
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
|
||||
function flowLineLayout() {
|
||||
return {
|
||||
visibility: "visible" as const,
|
||||
"line-cap": "round" as const,
|
||||
"line-join": "round" as const
|
||||
};
|
||||
}
|
||||
|
||||
function setFlowLayerVisibility(
|
||||
map: MapLibreMap,
|
||||
visibility: "visible" | "none"
|
||||
) {
|
||||
[
|
||||
FLOW_LINE_LAYER_ID,
|
||||
FLOW_REVERSE_LINE_LAYER_ID
|
||||
].forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setFlowMockVisibility(
|
||||
map: MapLibreMap,
|
||||
visible: boolean
|
||||
) {
|
||||
const opacityByLayer = [
|
||||
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
|
||||
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
|
||||
] as const;
|
||||
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(
|
||||
layerId,
|
||||
property,
|
||||
directionExpression(direction, opacity, visible)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LayerExtension, type Layer } from "@deck.gl/core";
|
||||
|
||||
const flowPathUniforms = {
|
||||
name: "flowPath",
|
||||
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
|
||||
uniformTypes: { phase: "f32" }
|
||||
} as const;
|
||||
|
||||
export class FlowPathExtension extends LayerExtension {
|
||||
static extensionName = "FlowPathExtension";
|
||||
static defaultProps = { flowPhase: { type: "number", value: 0 } };
|
||||
|
||||
getShaders() {
|
||||
return {
|
||||
modules: [flowPathUniforms],
|
||||
inject: {
|
||||
"fs:DECKGL_FILTER_COLOR": `
|
||||
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
|
||||
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
|
||||
color.a *= 0.22 + flowPulse * 0.78;
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
draw(this: Layer) {
|
||||
const state = this.state as unknown as {
|
||||
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
|
||||
};
|
||||
state.model?.shaderInputs?.setProps({
|
||||
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
|
||||
opacity?: number;
|
||||
radiusMultiplier?: number;
|
||||
strokeMultiplier?: number;
|
||||
iconMultiplier?: number;
|
||||
};
|
||||
|
||||
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
|
||||
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
const values = patch as Record<string, unknown>;
|
||||
const allowed = geometry === "line"
|
||||
? ["color", "opacity", "widthMultiplier", "dash"]
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
|
||||
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
|
||||
if ("color" in values && !isColor(values.color)) return false;
|
||||
if ("fillColor" in values && !isColor(values.fillColor)) return false;
|
||||
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
|
||||
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
|
||||
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
|
||||
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
|
||||
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
if (patch.strokeMultiplier !== undefined) {
|
||||
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
|
||||
}
|
||||
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
|
||||
setLayout(
|
||||
map,
|
||||
`${groupId}-symbol`,
|
||||
"icon-size",
|
||||
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
|
||||
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
|
||||
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
|
||||
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
|
||||
if ("iconSize" in visual) {
|
||||
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
function scale(value: unknown, multiplier: number): unknown {
|
||||
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
|
||||
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
||||
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
|
||||
map.setLayoutProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
}
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]]], {
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
});
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
],
|
||||
{
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
}
|
||||
);
|
||||
}
|
||||
return result;
|
||||
*/
|
||||
}
|
||||
|
||||
clearScadaAnalysis() {
|
||||
@@ -256,19 +415,31 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
resetAll = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
const map = this.options.getMap();
|
||||
if (map) {
|
||||
hideFlowOverlay(map, this.flowState);
|
||||
this.flowUpdateRevision += 1;
|
||||
void clearFlowCalculations(map, this.flowState);
|
||||
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
|
||||
}
|
||||
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
|
||||
}
|
||||
this.patchState({
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
});
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.flowUpdateRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.listeners.clear();
|
||||
@@ -299,15 +470,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
||||
const response = await fetch("/api/map-features", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId, featureIds }),
|
||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||
if (!parsed.ok) throw new Error(parsed.code);
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||
headers: { Accept: "application/geo+json, application/json" },
|
||||
signal
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
|
||||
return body as FeatureCollection;
|
||||
if (!response.ok) throw new Error("WFS_UNAVAILABLE");
|
||||
const maxFeatures = featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES;
|
||||
const collection = normalizeMapFeatureCollection(await response.json(), maxFeatures);
|
||||
if (!collection) throw new Error("WFS_UNAVAILABLE");
|
||||
return collection;
|
||||
}
|
||||
|
||||
private setHighlight(target: FeatureTarget) {
|
||||
@@ -316,7 +489,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||
this.highlightedTarget = target;
|
||||
this.clearValueLabel();
|
||||
this.patchState({ target, errorCode: null });
|
||||
}
|
||||
|
||||
@@ -339,6 +511,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyPipeFlowSummary(): PipeFlowSummary {
|
||||
return { total: 0, forward: 0, reverse: 0, stopped: 0, invalid: 0 };
|
||||
}
|
||||
|
||||
function toFeatureStateTarget(target: FeatureTarget) {
|
||||
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -65,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 {
|
||||
@@ -75,11 +113,27 @@
|
||||
--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.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);
|
||||
@@ -88,6 +142,10 @@
|
||||
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
|
||||
--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);
|
||||
@@ -96,26 +154,51 @@
|
||||
--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%;
|
||||
@@ -138,24 +221,18 @@
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
[data-basemap-tone="light"] {
|
||||
--acrylic-navigation: rgba(234, 243, 250, 0.64);
|
||||
--acrylic-panel: rgba(223, 235, 246, 0.56);
|
||||
--acrylic-control: rgba(242, 248, 253, 0.74);
|
||||
--acrylic-panel-blur: 24px;
|
||||
--acrylic-control-blur: 18px;
|
||||
--acrylic-backdrop-saturation: 1.08;
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.1);
|
||||
--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);
|
||||
}
|
||||
|
||||
[data-basemap-tone="satellite"] {
|
||||
--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;
|
||||
@@ -169,6 +246,10 @@
|
||||
--shadow-panel: 0 14px 36px rgba(7, 18, 32, 0.22);
|
||||
--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);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -188,12 +269,91 @@ body {
|
||||
-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)),
|
||||
@@ -210,10 +370,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;
|
||||
@@ -249,6 +405,13 @@ input:focus-visible {
|
||||
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 {
|
||||
background-color: var(--acrylic-control);
|
||||
box-shadow:
|
||||
@@ -294,32 +457,101 @@ input:focus-visible {
|
||||
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);
|
||||
}
|
||||
|
||||
.material-tone-agent {
|
||||
background-color: var(--tone-agent);
|
||||
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,
|
||||
@@ -332,6 +564,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,
|
||||
@@ -349,28 +593,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,
|
||||
@@ -379,10 +620,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;
|
||||
@@ -393,6 +630,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 {
|
||||
@@ -400,14 +655,114 @@ 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 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 1px 1px;
|
||||
border-bottom-right-radius: var(--radius-panel);
|
||||
border-bottom-left-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.agent-panel-header-mobile {
|
||||
inset-inline: -0.25rem;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -415,11 +770,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;
|
||||
@@ -475,76 +906,49 @@ 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;
|
||||
color: #ffffff;
|
||||
@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
|
||||
);
|
||||
--scheduled-feed-timeline-width: calc(var(--workbench-condition-width) - 1.5rem - 2px);
|
||||
transition:
|
||||
width 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 150ms ease-out;
|
||||
@@ -570,11 +974,6 @@ input:focus-visible {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-mobile .scheduled-feed-detail-scroll {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.mobile-workbench-sheet {
|
||||
overscroll-behavior: contain;
|
||||
touch-action: manipulation;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,31 +1,260 @@
|
||||
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);
|
||||
|
||||
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 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(historyBox!.x, headerBox!.x + 1);
|
||||
expectRoundedValue(historyBox!.width, headerBox!.width - 2);
|
||||
expect(headerBox!.width).toBeLessThanOrEqual(agentPanelBox!.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 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);
|
||||
|
||||
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 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 - 2);
|
||||
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 +266,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 +275,148 @@ 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 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("1px");
|
||||
expect(presentation.borderRightWidth).toBe("1px");
|
||||
expect(presentation.borderTopWidth).toBe("0px");
|
||||
expect(presentation.boxShadow).not.toBe("none");
|
||||
}
|
||||
|
||||
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 +436,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Agent panel resizes by drag without exceeding the 620px workspace limit", 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 命令面板"]');
|
||||
@@ -18,15 +16,15 @@ test("Agent panel resizes by drag without exceeding the 620px workspace limit",
|
||||
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(620);
|
||||
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(620);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
||||
});
|
||||
|
||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
||||
@@ -61,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.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.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -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,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[] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("mobile workbench sheet", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
@@ -34,6 +34,8 @@ test.describe("mobile workbench sheet", () => {
|
||||
|
||||
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
|
||||
);
|
||||
@@ -55,8 +57,8 @@ test.describe("mobile workbench sheet", () => {
|
||||
})
|
||||
)
|
||||
.toEqual({
|
||||
backdropFilter: "blur(24px) saturate(1.08)",
|
||||
nestedFilterCount: 0,
|
||||
backdropFilter: "blur(18px) saturate(1.08)",
|
||||
nestedFilterCount: 1,
|
||||
opacity: "1",
|
||||
transform: "none"
|
||||
});
|
||||
@@ -113,8 +115,11 @@ test.describe("mobile workbench sheet", () => {
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
const closeButton = page.getByRole("button", { name: "关闭工况任务" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭工况任务" }).click();
|
||||
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();
|
||||
});
|
||||
@@ -126,6 +131,9 @@ test.describe("mobile workbench sheet", () => {
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
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)
|
||||
);
|
||||
@@ -33,9 +37,32 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
|
||||
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 page.waitForTimeout(250);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
const restoredTimelineBox = await timeline.boundingBox();
|
||||
expect(restoredTimelineBox).not.toBeNull();
|
||||
@@ -82,6 +109,94 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
|
||||
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" });
|
||||
|
||||
@@ -93,13 +208,47 @@ test("shows completed field work as a static waiting-for-reply state", async ({
|
||||
.filter({ hasText: "系统工单:泵站边界复核复令" });
|
||||
|
||||
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
|
||||
await expect(awaitingReplyWorkOrder.locator(".animate-spin")).toHaveCount(0);
|
||||
await expect(awaitingReplyWorkOrder.locator(".bg-orange-50")).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);
|
||||
@@ -142,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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
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" });
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator, type Page } from "playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EMPTY_RASTER_TILE = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
@@ -19,7 +19,8 @@ test.describe("neutral blue mist workbench", () => {
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.64,
|
||||
panel: 0.56,
|
||||
agentPanel: 0.82,
|
||||
conditionPanel: 0.56,
|
||||
control: 0.74
|
||||
});
|
||||
await expectLoadedChineseFont(page);
|
||||
@@ -39,7 +40,8 @@ test.describe("neutral blue mist workbench", () => {
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.8,
|
||||
panel: 0.72,
|
||||
agentPanel: 0.88,
|
||||
conditionPanel: 0.72,
|
||||
control: 0.86
|
||||
});
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||
@@ -58,12 +60,13 @@ test.describe("neutral blue mist workbench", () => {
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(agentPanel).toHaveCSS("width", "620px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 768);
|
||||
await expect(agentPanel).toHaveCSS("width", "720px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 680);
|
||||
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(conditionPanel).toHaveCSS("width", "960px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 260);
|
||||
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
|
||||
@@ -185,11 +188,16 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
||||
|
||||
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", "460px");
|
||||
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();
|
||||
@@ -212,9 +220,7 @@ async function expectMapCorridor(
|
||||
const conditionBox = await conditionPanel.boundingBox();
|
||||
expect(agentBox).not.toBeNull();
|
||||
expect(conditionBox).not.toBeNull();
|
||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(
|
||||
minimumWidth
|
||||
);
|
||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
|
||||
}
|
||||
|
||||
async function expectLoadedChineseFont(page: Page) {
|
||||
@@ -227,12 +233,20 @@ async function expectLoadedChineseFont(page: Page) {
|
||||
|
||||
async function expectAcrylicAlphas(
|
||||
page: Page,
|
||||
expected: { navigation: number; panel: number; control: number }
|
||||
expected: {
|
||||
navigation: number;
|
||||
agentPanel: number;
|
||||
conditionPanel: number;
|
||||
control: number;
|
||||
}
|
||||
) {
|
||||
const alphas = await page.evaluate(() => {
|
||||
return {
|
||||
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
|
||||
panel: alphaOf(document.querySelector(".acrylic-panel")),
|
||||
agentPanel: alphaOf(
|
||||
document.querySelector('aside[aria-label="Agent 命令面板"]')
|
||||
),
|
||||
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
|
||||
control: alphaOf(document.querySelector(".acrylic-control"))
|
||||
};
|
||||
|
||||
@@ -247,7 +261,8 @@ async function expectAcrylicAlphas(
|
||||
});
|
||||
|
||||
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
|
||||
expect(alphas.panel).toBeCloseTo(expected.panel, 2);
|
||||
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
|
||||
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
|
||||
expect(alphas.control).toBeCloseTo(expected.control, 2);
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 429 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 853 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
@@ -47,6 +47,9 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
plugins: [runtimeConfigPlugin(env), react(), tailwindcss()],
|
||||
server: {
|
||||
allowedHosts: ["next.demo.waternetwork.cn"]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
|
||||