Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf4436899 | ||
|
|
d29792a5c5 | ||
|
|
d1e69ad59b | ||
|
|
7216ffd60f | ||
|
|
b1b68d2754 | ||
|
|
2c35f500df | ||
|
|
3ba62ceacb | ||
|
|
73896a893f | ||
|
|
f4318a9b9c | ||
|
|
ade88b4fbf | ||
|
|
f8c79a2af3 | ||
|
|
f0485ca86e |
@@ -0,0 +1,22 @@
|
||||
name: Frontend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-test-publish-and-deploy:
|
||||
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||
with:
|
||||
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-frontend
|
||||
dockerfile: Dockerfile
|
||||
build_context: .
|
||||
test_target: build
|
||||
deploy_service: next-frontend
|
||||
deploy_host: 192.168.1.114
|
||||
secrets:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
This repository contains the Vite and React single-page frontend for the
|
||||
Agent-driven supply-network WebGIS. Runtime TypeScript lives under `src/`.
|
||||
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, deck.gl,
|
||||
SWR, Vitest, and Playwright.
|
||||
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, SWR,
|
||||
Vitest, and Playwright.
|
||||
|
||||
Keep this project browser-first. Do not add Next.js APIs, `"use client"`
|
||||
directives, or general backend-for-frontend routes. Agent reasoning and tool
|
||||
|
||||
+12
-4
@@ -1,17 +1,23 @@
|
||||
FROM node:24-alpine AS build
|
||||
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
|
||||
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
||||
ENV npm_config_registry=${NPM_REGISTRY}
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM caddy:2.10.2-alpine
|
||||
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
||||
|
||||
RUN apk add --no-cache jq nodejs
|
||||
ARG ALPINE_MIRROR=mirrors.aliyun.com
|
||||
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
|
||||
apk add --no-cache jq nodejs
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
|
||||
COPY --from=build /app/dist /srv
|
||||
@@ -19,5 +25,7 @@ COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-fr
|
||||
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以地图为主视图,集成供水管网图层、调度状态、事件处置、Agent 对话和受控前端动作。
|
||||
|
||||
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
|
||||
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图与流向效果基于 MapLibre GL,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -70,6 +70,17 @@ docker run --rm -p 8080:80 --env-file .env.local next-tjwater
|
||||
|
||||
启动后访问 `http://localhost:8080`。
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions 通过 `.gitea/workflows/package.yml` 复用
|
||||
`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev
|
||||
服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会
|
||||
提升为正式 `frontend` 服务。
|
||||
|
||||
仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和
|
||||
`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。生产运行时配置由 Dev 服务器的
|
||||
`env/frontend.env` 注入,不写入镜像或仓库 Secret。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
@@ -87,4 +98,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`,再通过对比度测试验证,不要在图层定义中
|
||||
写入新的颜色字面量。
|
||||
+7
-1
@@ -3,7 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TJWater WebGIS Agent</title>
|
||||
<link
|
||||
rel="preload"
|
||||
as="fetch"
|
||||
href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<title>供水管网</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+4
-6
@@ -6,7 +6,8 @@
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && pnpm build:tts",
|
||||
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
|
||||
"build:verify-css": "node scripts/verify-production-css.mjs",
|
||||
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -18,9 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.9",
|
||||
"@deck.gl/core": "^9.3.7",
|
||||
"@deck.gl/layers": "^9.3.7",
|
||||
"@deck.gl/mapbox": "^9.3.7",
|
||||
"@fontsource-variable/noto-sans-sc": "5.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.14",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
@@ -72,14 +71,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",
|
||||
|
||||
@@ -10,6 +10,9 @@ export default defineConfig({
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
deviceScaleFactor: 1,
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
|
||||
Generated
+9
-374
@@ -11,15 +11,9 @@ importers:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.34(react@19.2.7)(zod@3.25.76)
|
||||
'@deck.gl/core':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7
|
||||
'@deck.gl/layers':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))
|
||||
'@deck.gl/mapbox':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
|
||||
'@fontsource-variable/noto-sans-sc':
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -189,9 +183,6 @@ importers:
|
||||
msw:
|
||||
specifier: ^2.6.8
|
||||
version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
|
||||
playwright:
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.9.5
|
||||
@@ -454,24 +445,6 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@deck.gl/core@9.3.7':
|
||||
resolution: {integrity: sha512-l+YPm1I0Fwj+b7xsu8FvyDT5MGPgEPJe2cQJUpQNgG742b2goB4tyYpteUhNAOeVo1gmenvqv36ZJGNsGxipJA==}
|
||||
|
||||
'@deck.gl/layers@9.3.7':
|
||||
resolution: {integrity: sha512-T2CnkEU1QEqmusrSdot7+8Nl09gzntG5DvhaogyBbTjmABhTh4rUnS6dLOjf5/ju3aJ+Q58/FvDzgBBcK4V7kg==}
|
||||
peerDependencies:
|
||||
'@deck.gl/core': ~9.3.0
|
||||
'@loaders.gl/core': ^4.4.3
|
||||
'@luma.gl/core': ~9.3.3
|
||||
'@luma.gl/engine': ~9.3.3
|
||||
|
||||
'@deck.gl/mapbox@9.3.7':
|
||||
resolution: {integrity: sha512-hamgwBS2BbB8DHOjYWwPMrFki+KhcedjoHv/9cUpQHnjfXUswzmyhAURuyowO/tAVJb3vF+CmMzaDVUTlI40Sw==}
|
||||
peerDependencies:
|
||||
'@deck.gl/core': ~9.3.0
|
||||
'@luma.gl/core': ~9.3.3
|
||||
'@math.gl/web-mercator': ^4.1.0
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -681,6 +654,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0':
|
||||
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -758,49 +734,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@loaders.gl/core@4.4.3':
|
||||
resolution: {integrity: sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==}
|
||||
|
||||
'@loaders.gl/images@4.4.3':
|
||||
resolution: {integrity: sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@loaders.gl/loader-utils@4.4.3':
|
||||
resolution: {integrity: sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==}
|
||||
|
||||
'@loaders.gl/schema-utils@4.4.3':
|
||||
resolution: {integrity: sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@loaders.gl/schema@4.4.3':
|
||||
resolution: {integrity: sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==}
|
||||
|
||||
'@loaders.gl/worker-utils@4.4.3':
|
||||
resolution: {integrity: sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@luma.gl/core@9.3.6':
|
||||
resolution: {integrity: sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==}
|
||||
|
||||
'@luma.gl/engine@9.3.6':
|
||||
resolution: {integrity: sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
'@luma.gl/shadertools': ~9.3.0
|
||||
|
||||
'@luma.gl/shadertools@9.3.6':
|
||||
resolution: {integrity: sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
|
||||
'@luma.gl/webgl@9.3.6':
|
||||
resolution: {integrity: sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
|
||||
hasBin: true
|
||||
@@ -829,21 +762,6 @@ packages:
|
||||
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
|
||||
hasBin: true
|
||||
|
||||
'@math.gl/core@4.1.0':
|
||||
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
|
||||
|
||||
'@math.gl/polygon@4.1.0':
|
||||
resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==}
|
||||
|
||||
'@math.gl/sun@4.1.0':
|
||||
resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==}
|
||||
|
||||
'@math.gl/types@4.1.0':
|
||||
resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==}
|
||||
|
||||
'@math.gl/web-mercator@4.1.0':
|
||||
resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==}
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
@@ -871,15 +789,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@probe.gl/env@4.1.1':
|
||||
resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==}
|
||||
|
||||
'@probe.gl/log@4.1.1':
|
||||
resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==}
|
||||
|
||||
'@probe.gl/stats@4.1.1':
|
||||
resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==}
|
||||
|
||||
'@radix-ui/number@1.1.2':
|
||||
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
|
||||
|
||||
@@ -1708,12 +1617,6 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/command-line-args@5.2.3':
|
||||
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
|
||||
|
||||
'@types/command-line-usage@5.0.4':
|
||||
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -1849,9 +1752,6 @@ packages:
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/pbf@3.0.5':
|
||||
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
|
||||
|
||||
@@ -2084,10 +1984,6 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
apache-arrow@21.1.0:
|
||||
resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==}
|
||||
hasBin: true
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -2102,10 +1998,6 @@ packages:
|
||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
array-back@6.2.3:
|
||||
resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2150,10 +2042,6 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2205,19 +2093,6 @@ packages:
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
command-line-args@6.0.2:
|
||||
resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==}
|
||||
engines: {node: '>=12.20'}
|
||||
peerDependencies:
|
||||
'@75lb/nature': latest
|
||||
peerDependenciesMeta:
|
||||
'@75lb/nature':
|
||||
optional: true
|
||||
|
||||
command-line-usage@7.0.4:
|
||||
resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2475,9 +2350,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
earcut@2.2.4:
|
||||
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
|
||||
|
||||
earcut@3.2.3:
|
||||
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
|
||||
|
||||
@@ -2660,15 +2532,6 @@ packages:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
find-replace@5.0.2:
|
||||
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@75lb/nature': latest
|
||||
peerDependenciesMeta:
|
||||
'@75lb/nature':
|
||||
optional: true
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2677,9 +2540,6 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatbuffers@25.9.23:
|
||||
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
|
||||
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
@@ -2968,10 +2828,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bignum@0.0.3:
|
||||
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@@ -3104,9 +2960,6 @@ packages:
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3355,9 +3208,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mjolnir.js@3.0.1:
|
||||
resolution: {integrity: sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ==}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
|
||||
|
||||
@@ -3799,10 +3649,6 @@ packages:
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
table-layout@4.1.1:
|
||||
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3897,10 +3743,6 @@ packages:
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
typical@7.3.0:
|
||||
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
@@ -4114,10 +3956,6 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wordwrapjs@5.1.1:
|
||||
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4485,51 +4323,6 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@deck.gl/core@9.3.7':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@luma.gl/webgl': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/sun': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
gl-matrix: 3.4.4
|
||||
mjolnir.js: 3.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@deck.gl/layers@9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))':
|
||||
dependencies:
|
||||
'@deck.gl/core': 9.3.7
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@mapbox/tiny-sdf': 2.2.0
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/polygon': 4.1.0
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
'@types/geojson': 7946.0.16
|
||||
earcut: 2.2.4
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@deck.gl/mapbox@9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)':
|
||||
dependencies:
|
||||
'@deck.gl/core': 9.3.7
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
@@ -4671,6 +4464,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -4741,82 +4536,6 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@loaders.gl/core@4.4.3':
|
||||
dependencies:
|
||||
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@loaders.gl/schema-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@probe.gl/log': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/images@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/loader-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
- '@loaders.gl/core'
|
||||
|
||||
'@loaders.gl/schema-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@types/geojson': 7946.0.16
|
||||
apache-arrow: 21.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/schema@4.4.3':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
apache-arrow: 21.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/worker-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
|
||||
'@luma.gl/core@9.3.6':
|
||||
dependencies:
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
|
||||
'@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
|
||||
'@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
|
||||
'@luma.gl/webgl@9.3.6(@luma.gl/core@9.3.6)':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
dependencies:
|
||||
get-stream: 6.0.1
|
||||
@@ -4846,22 +4565,6 @@ snapshots:
|
||||
rw: 1.3.3
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
'@math.gl/core@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/types': 4.1.0
|
||||
|
||||
'@math.gl/polygon@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/core': 4.1.0
|
||||
|
||||
'@math.gl/sun@4.1.0': {}
|
||||
|
||||
'@math.gl/types@4.1.0': {}
|
||||
|
||||
'@math.gl/web-mercator@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/core': 4.1.0
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
@@ -4894,14 +4597,6 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@probe.gl/env@4.1.1': {}
|
||||
|
||||
'@probe.gl/log@4.1.1':
|
||||
dependencies:
|
||||
'@probe.gl/env': 4.1.1
|
||||
|
||||
'@probe.gl/stats@4.1.1': {}
|
||||
|
||||
'@radix-ui/number@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.6': {}
|
||||
@@ -5542,6 +5237,7 @@ snapshots:
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
dependencies:
|
||||
@@ -5656,10 +5352,6 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/command-line-args@5.2.3': {}
|
||||
|
||||
'@types/command-line-usage@5.0.4': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -5821,8 +5513,6 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/pbf@3.0.5': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
@@ -6001,20 +5691,6 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
apache-arrow@21.1.0:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
'@types/command-line-args': 5.2.3
|
||||
'@types/command-line-usage': 5.0.4
|
||||
'@types/node': 24.13.3
|
||||
command-line-args: 6.0.2
|
||||
command-line-usage: 7.0.4
|
||||
flatbuffers: 25.9.23
|
||||
json-bignum: 0.0.3
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
@@ -6027,8 +5703,6 @@ snapshots:
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
|
||||
array-back@6.2.3: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -6065,10 +5739,6 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -6120,20 +5790,6 @@ snapshots:
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
command-line-args@6.0.2:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
find-replace: 5.0.2
|
||||
lodash.camelcase: 4.3.0
|
||||
typical: 7.3.0
|
||||
|
||||
command-line-usage@7.0.4:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
chalk-template: 0.4.0
|
||||
table-layout: 4.1.1
|
||||
typical: 7.3.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -6402,8 +6058,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
earcut@2.2.4: {}
|
||||
|
||||
earcut@3.2.3: {}
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
|
||||
@@ -6615,8 +6269,6 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
||||
find-replace@5.0.2: {}
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -6627,8 +6279,6 @@ snapshots:
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flatbuffers@25.9.23: {}
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
form-data@4.0.6:
|
||||
@@ -6973,8 +6623,6 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bignum@0.0.3: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
@@ -7069,8 +6717,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
@@ -7586,8 +7232,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mjolnir.js@3.0.1: {}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
@@ -8091,11 +7735,6 @@ snapshots:
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
table-layout@4.1.1:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
wordwrapjs: 5.1.1
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
@@ -8186,8 +7825,6 @@ snapshots:
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
typical@7.3.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -8369,8 +8006,6 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { stdout } from "node:process";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const assetsDirectory = fileURLToPath(new URL("../dist/assets/", import.meta.url));
|
||||
const assetNames = await readdir(assetsDirectory);
|
||||
const stylesheetName = assetNames.find((name) => /^index-.*\.css$/.test(name));
|
||||
|
||||
if (!stylesheetName) {
|
||||
throw new Error("Production CSS verification failed: index stylesheet was not emitted.");
|
||||
}
|
||||
|
||||
const stylesheet = await readFile(path.join(assetsDirectory, stylesheetName), "utf8");
|
||||
const selector = ".agent-panel-shell.acrylic-panel";
|
||||
const selectorOffset = stylesheet.indexOf(selector);
|
||||
const ruleStart = stylesheet.indexOf("{", selectorOffset);
|
||||
const ruleEnd = stylesheet.indexOf("}", ruleStart);
|
||||
|
||||
if (selectorOffset < 0 || ruleStart < 0 || ruleEnd < 0) {
|
||||
throw new Error(`Production CSS verification failed: ${selector} rule is missing.`);
|
||||
}
|
||||
|
||||
const declarations = stylesheet
|
||||
.slice(ruleStart + 1, ruleEnd)
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim());
|
||||
|
||||
if (!declarations.some((declaration) => declaration.startsWith("backdrop-filter:"))) {
|
||||
throw new Error(
|
||||
"Production CSS verification failed: the standard backdrop-filter declaration was removed."
|
||||
);
|
||||
}
|
||||
|
||||
stdout.write(`Verified standard backdrop-filter in ${stylesheetName}.\n`);
|
||||
+4
-4
@@ -80,7 +80,7 @@ test("preserves compact typography in header and agent controls", async ({ page
|
||||
await expect(approvalControl).toHaveCSS("font-size", "12px");
|
||||
await expect(approvalControl).toHaveCSS("line-height", "16px");
|
||||
await expect(suggestion).toHaveCSS("font-size", "12px");
|
||||
await expect(suggestion).toHaveCSS("font-weight", "500");
|
||||
await expect(suggestion).toHaveCSS("font-weight", "600");
|
||||
});
|
||||
|
||||
test("lets interactive utilities override surface materials", async ({ page }) => {
|
||||
@@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
||||
expect(hoverBackground).not.toBe(restingBackground);
|
||||
});
|
||||
|
||||
test("preserves v3 menu and panel styling through Tailwind v4", async ({ page }) => {
|
||||
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
|
||||
@@ -116,6 +116,6 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
|
||||
const commandShadow = await agentCommandControl.evaluate(
|
||||
(element) => window.getComputedStyle(element).boxShadow
|
||||
);
|
||||
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
|
||||
expect(commandShadow).not.toContain("0px 1px 3px 0px");
|
||||
expect(commandShadow).toBe("none");
|
||||
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
@@ -21,8 +20,7 @@ export function AgentCollapsedRail({
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
|
||||
>
|
||||
<AgentPersona className="h-11 w-11" state={personaState} />
|
||||
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
||||
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Volume2
|
||||
Volume2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import {
|
||||
@@ -69,6 +71,7 @@ import {
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentConnectionStatus,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
@@ -77,7 +80,10 @@ import type {
|
||||
AgentUiResult
|
||||
} from "../types";
|
||||
|
||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
presentation?: AgentCommandPanelPresentation;
|
||||
collapsing?: boolean;
|
||||
personaState?: PersonaState;
|
||||
sessionTitle?: string;
|
||||
@@ -85,6 +91,7 @@ type AgentCommandPanelProps = {
|
||||
sessionHistoryLoading?: boolean;
|
||||
activeSessionId?: string | null;
|
||||
statusLabel?: string;
|
||||
connectionStatus?: AgentConnectionStatus;
|
||||
streaming?: boolean;
|
||||
messages?: AgentChatMessage[];
|
||||
streamRenderState?: AgentStreamRenderState;
|
||||
@@ -127,6 +134,7 @@ const AGENT_CAPABILITIES = [
|
||||
] as const;
|
||||
|
||||
export function AgentCommandPanel({
|
||||
presentation = "desktop-dock",
|
||||
collapsing = false,
|
||||
personaState,
|
||||
sessionTitle = "新对话",
|
||||
@@ -134,6 +142,7 @@ export function AgentCommandPanel({
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
connectionStatus = "connecting",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
streamRenderState = {},
|
||||
@@ -159,6 +168,12 @@ export function AgentCommandPanel({
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const historyPanelId = useId();
|
||||
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const historyPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
|
||||
const hasConversation = messages.length > 0 || streaming;
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const {
|
||||
speechState,
|
||||
@@ -194,6 +209,46 @@ export function AgentCommandPanel({
|
||||
}
|
||||
}, [historyOpen, onRefreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
historyPanelRef.current?.contains(target) ||
|
||||
historyTriggerRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
const closeHistoryOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHistoryOpen(false);
|
||||
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||
document.addEventListener("keydown", closeHistoryOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||
document.removeEventListener("keydown", closeHistoryOnEscape);
|
||||
};
|
||||
}, [historyOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
stopSpeech();
|
||||
}, [activeSessionId, stopSpeech]);
|
||||
@@ -213,21 +268,62 @@ export function AgentCommandPanel({
|
||||
setPrompt(nextPrompt);
|
||||
});
|
||||
};
|
||||
const historyPanel = (
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
);
|
||||
const operationalBrief = (
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<aside
|
||||
aria-label="Agent 命令面板"
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||
"agent-panel-shell",
|
||||
"acrylic-panel border",
|
||||
"rounded-2xl",
|
||||
presentation === "desktop-dock"
|
||||
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r"
|
||||
: desktopFloating
|
||||
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<header className="agent-panel-header">
|
||||
<Agent
|
||||
className={cn(
|
||||
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
|
||||
floatingPresentation && "gap-1 overflow-visible p-1"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-30 h-16 shrink-0">
|
||||
<header
|
||||
className={cn(
|
||||
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
|
||||
floatingPresentation && "agent-panel-integrated-header",
|
||||
presentation === "mobile-sheet" && "agent-panel-header-mobile",
|
||||
historyOpen && "agent-panel-header-expanded"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
@@ -237,15 +333,17 @@ export function AgentCommandPanel({
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
getAgentConnectionClassName(statusLabel)
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
icon={
|
||||
<StatusDot
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||||
/>
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{agentConnectionMetadata[connectionStatus].label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
@@ -265,86 +363,122 @@ export function AgentCommandPanel({
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref={historyTriggerRef}
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyPanelId}
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
)}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{presentation === "mobile-sheet" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭 Agent 面板"
|
||||
title="关闭 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
ref={historyPanelRef}
|
||||
key="history"
|
||||
id={historyPanelId}
|
||||
role="region"
|
||||
aria-label="Agent 历史记录"
|
||||
className="agent-panel-floating-acrylic agent-panel-history-extension"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
{historyPanel}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{floatingPresentation ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
aria-hidden={historyOpen}
|
||||
className={cn(
|
||||
"space-y-2 px-3 pb-3",
|
||||
historyOpen && "invisible pointer-events-none"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<Conversation
|
||||
className={cn(
|
||||
"agent-panel-conversation min-h-0",
|
||||
floatingPresentation && "agent-panel-conversation-canvas"
|
||||
)}
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
||||
className={cn(
|
||||
"gap-4 p-3",
|
||||
messages.length === 0 && "min-h-full",
|
||||
floatingPresentation && "agent-panel-conversation-content",
|
||||
floatingPresentation &&
|
||||
hasConversation &&
|
||||
"agent-panel-conversation-content-has-brief",
|
||||
floatingPresentation &&
|
||||
!hasConversation &&
|
||||
"agent-panel-conversation-content-empty"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
@@ -373,7 +507,7 @@ export function AgentCommandPanel({
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState />
|
||||
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -384,14 +518,32 @@ export function AgentCommandPanel({
|
||||
activeSessionId={activeSessionId}
|
||||
messages={messages}
|
||||
scrollRequestId={scrollRequestId}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<ConversationScrollButton
|
||||
className={cn(
|
||||
"agent-panel-floating-acrylic agent-conversation-scroll-button",
|
||||
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
|
||||
)}
|
||||
/>
|
||||
<ConversationScrollButton className="bottom-3" />
|
||||
</Conversation>
|
||||
</AgentContent>
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"agent-panel-band relative",
|
||||
floatingPresentation
|
||||
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||||
: "border-t border-slate-200 p-3"
|
||||
)}
|
||||
>
|
||||
{!hasConversation ? (
|
||||
<div
|
||||
className={cn(
|
||||
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
|
||||
floatingPresentation && "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Suggestions
|
||||
aria-label="推荐问题"
|
||||
role="group"
|
||||
@@ -411,7 +563,7 @@ export function AgentCommandPanel({
|
||||
</div>
|
||||
) : null}
|
||||
<PromptInput
|
||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||||
onSubmit={(message) => {
|
||||
const nextPrompt = message.text.trim();
|
||||
if (nextPrompt) {
|
||||
@@ -423,7 +575,7 @@ export function AgentCommandPanel({
|
||||
<PromptInputTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
className="min-h-14 text-sm"
|
||||
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
@@ -435,7 +587,7 @@ export function AgentCommandPanel({
|
||||
onValueChange={onApprovalModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
@@ -458,7 +610,13 @@ export function AgentCommandPanel({
|
||||
status={streaming ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
>
|
||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
||||
{streaming ? undefined : (
|
||||
<SendHorizontal
|
||||
size={15}
|
||||
style={{ transform: "rotate(-45deg)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
@@ -497,29 +655,38 @@ function VoiceInputButton({
|
||||
aria-pressed={isListening}
|
||||
title={label}
|
||||
className={cn(
|
||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
|
||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
|
||||
"border-transparent bg-transparent shadow-none",
|
||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||
"hover:bg-slate-50 hover:text-blue-700",
|
||||
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||||
"disabled:cursor-not-allowed disabled:opacity-45",
|
||||
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||
isListening
|
||||
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
|
||||
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
|
||||
>
|
||||
{isListening ? (
|
||||
<motion.span
|
||||
data-slot="voice-input-pulse"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={
|
||||
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
|
||||
reduceMotion
|
||||
? { opacity: 0.38, scale: 1 }
|
||||
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
|
||||
}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
|
||||
reduceMotion
|
||||
? { duration: 0.16 }
|
||||
: {
|
||||
duration: 1.7,
|
||||
ease: "easeOut",
|
||||
repeat: Infinity,
|
||||
times: [0, 0.12, 1]
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -539,7 +706,7 @@ function VoiceInputButton({
|
||||
{VOICE_WAVE_BARS.map((bar) => (
|
||||
<motion.span
|
||||
key={bar.height}
|
||||
className="w-px rounded-full bg-red-500"
|
||||
className="w-px rounded-full bg-[var(--status-mark)]"
|
||||
style={{ height: bar.height }}
|
||||
animate={
|
||||
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||
@@ -598,7 +765,8 @@ function ApprovalModeSelect({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
disabled={disabled || !onValueChange}
|
||||
aria-label="权限批准模式"
|
||||
>
|
||||
@@ -639,17 +807,24 @@ function ApprovalModeSelect({
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
|
||||
!requestApproval &&
|
||||
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
|
||||
)}
|
||||
onSelect={() => onValueChange?.("always")}
|
||||
>
|
||||
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||
<ShieldCheck
|
||||
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||||
</span>
|
||||
{!requestApproval ? (
|
||||
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||
<CheckCircle2
|
||||
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -659,29 +834,36 @@ function ApprovalModeSelect({
|
||||
|
||||
type AgentConversationScrollSnapshot = {
|
||||
activeSessionId?: string | null;
|
||||
lastMessageRevision: string;
|
||||
lastUserMessageId: string | null;
|
||||
scrollRequestId: number;
|
||||
streaming: boolean;
|
||||
};
|
||||
|
||||
function AgentConversationScrollManager({
|
||||
activeSessionId,
|
||||
messages,
|
||||
scrollRequestId
|
||||
scrollRequestId,
|
||||
streaming
|
||||
}: {
|
||||
activeSessionId?: string | null;
|
||||
messages: AgentChatMessage[];
|
||||
scrollRequestId: number;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
||||
const lastUserMessageId = getLastUserMessageId(messages);
|
||||
const lastMessageRevision = getLastMessageRevision(messages);
|
||||
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
||||
() => ({
|
||||
activeSessionId,
|
||||
lastMessageRevision,
|
||||
lastUserMessageId,
|
||||
scrollRequestId
|
||||
scrollRequestId,
|
||||
streaming
|
||||
}),
|
||||
[activeSessionId, lastUserMessageId, scrollRequestId]
|
||||
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -693,7 +875,10 @@ function AgentConversationScrollManager({
|
||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||
(Boolean(snapshot.lastUserMessageId) &&
|
||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
|
||||
(snapshot.streaming &&
|
||||
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
|
||||
previousSnapshot.streaming !== snapshot.streaming;
|
||||
|
||||
if (!forceScroll) {
|
||||
return;
|
||||
@@ -713,6 +898,19 @@ function AgentConversationScrollManager({
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLastMessageRevision(messages: AgentChatMessage[]) {
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
if (!lastMessage) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const progressRevision =
|
||||
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
|
||||
|
||||
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
|
||||
}
|
||||
|
||||
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "user") {
|
||||
@@ -746,7 +944,8 @@ function AgentModelSelect({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
size="sm"
|
||||
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
disabled={!onValueChange}
|
||||
aria-label="选择 Agent 模型"
|
||||
>
|
||||
@@ -801,13 +1000,11 @@ function AgentModelSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function getAgentConnectionLabel(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
|
||||
}
|
||||
|
||||
function getAgentConnectionClassName(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
|
||||
}
|
||||
const agentConnectionMetadata = {
|
||||
connecting: { label: "连接中", tone: "info" },
|
||||
online: { label: "在线", tone: "success" },
|
||||
offline: { label: "离线", tone: "danger" }
|
||||
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
|
||||
|
||||
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
||||
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
|
||||
@@ -875,50 +1072,66 @@ function AgentReadyMark() {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState() {
|
||||
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
|
||||
className="mx-auto w-full max-w-[360px]"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className="px-4 py-5">
|
||||
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
|
||||
<div
|
||||
className={cn(
|
||||
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
|
||||
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||||
)}
|
||||
>
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className="text-balance text-lg font-semibold leading-7 text-slate-900"
|
||||
className={cn(
|
||||
"text-balance font-semibold text-slate-950",
|
||||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||
)}
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
|
||||
<p
|
||||
className={cn(
|
||||
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||||
compact ? "mt-3" : "mt-5"
|
||||
)}
|
||||
>
|
||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
|
||||
className={cn(
|
||||
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
|
||||
compact && "hidden"
|
||||
)}
|
||||
aria-label="Agent 分析能力"
|
||||
>
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||||
<li
|
||||
key={label}
|
||||
className={cn(
|
||||
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
|
||||
index % 2 === 0 && "border-r border-slate-200/70",
|
||||
index < 2 && "border-b border-slate-200/70"
|
||||
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
|
||||
index % 2 === 0 && "border-r border-slate-300/80",
|
||||
index < 2 && "border-b border-slate-300/80"
|
||||
)}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
|
||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
|
||||
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -980,9 +1193,10 @@ function BackendMessageList({
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
||||
message.role === "user" &&
|
||||
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
|
||||
message.role === "assistant" &&
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -1168,21 +1382,16 @@ function AgentSpeechMessage({
|
||||
<motion.button
|
||||
key="speech-selection-action"
|
||||
type="button"
|
||||
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
||||
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
||||
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={speakFromSelection}
|
||||
>
|
||||
@@ -1270,20 +1479,21 @@ function SpeechIconButton({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
|
||||
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
|
||||
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
|
||||
tone === "primary" && "text-blue-600",
|
||||
tone === "danger" && "text-red-600"
|
||||
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
@@ -73,30 +75,35 @@ export function AgentHistoryPanel({
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={loading && sessions.length === 0}
|
||||
loading={loading && sessions.length > 0}
|
||||
loadingLabel="正在刷新 Agent 历史记录"
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
||||
<motion.div
|
||||
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||
active && !editing
|
||||
? "agent-history-item-active border border-blue-200"
|
||||
: "border border-transparent"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
||||
<motion.div
|
||||
key="confirm-delete"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<span
|
||||
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||
title={session.title}
|
||||
>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 rounded-lg bg-white text-xs"
|
||||
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
@@ -168,7 +187,7 @@ export function AgentHistoryPanel({
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
className="agent-history-session-trigger min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -176,7 +195,10 @@ export function AgentHistoryPanel({
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
|
||||
<span
|
||||
className="block truncate text-xs font-semibold text-slate-900"
|
||||
title={session.title}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
@@ -185,79 +207,89 @@ export function AgentHistoryPanel({
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<StatusDot tone="info" activity="live" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
|
||||
<Loader2
|
||||
size={13}
|
||||
className="animate-spin text-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
当前
|
||||
</span>
|
||||
<StatusBadge tone="info">当前</StatusBadge>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={deleting}
|
||||
loadingLabel="正在删除对话"
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
size="icon-sm"
|
||||
loading={saving}
|
||||
loadingLabel="正在保存对话主题"
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
@@ -265,7 +297,7 @@ export function AgentHistoryPanel({
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -273,7 +305,14 @@ export function AgentHistoryPanel({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<motion.div
|
||||
key="history-empty"
|
||||
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentMessageDetails } from "./agent-message-details";
|
||||
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgentMessageDetails question answers", () => {
|
||||
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析范围",
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{ label: "雨污混接分析", description: "检查混接问题" },
|
||||
{ label: "水质异常分析", description: "检查水质指标" }
|
||||
]
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
|
||||
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["检查高峰时段压力"]]
|
||||
);
|
||||
});
|
||||
|
||||
it("combines selected options with a custom answer for a multiple-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析内容",
|
||||
question: "需要包含哪些内容?",
|
||||
options: [
|
||||
{ label: "压力", description: "分析节点压力" },
|
||||
{ label: "流量", description: "分析管段流量" }
|
||||
],
|
||||
multiple: true,
|
||||
custom: true
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["压力", "补充水龄"]]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderQuestion(
|
||||
question: AgentQuestionInfo,
|
||||
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
|
||||
) {
|
||||
render(
|
||||
<AgentMessageDetails
|
||||
message={{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "需要补充信息",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [question],
|
||||
createdAt: 100,
|
||||
status: "pending"
|
||||
}
|
||||
]
|
||||
}}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,16 @@ import {
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusActivity,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import {
|
||||
agentEase,
|
||||
agentLayoutTransition,
|
||||
@@ -37,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
|
||||
|
||||
const STREAMING_PROGRESS_LIMIT = 3;
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
type AgentStatusMeta = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
activity?: StatusActivity;
|
||||
};
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
|
||||
running: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
tone: "info",
|
||||
activity: "live"
|
||||
},
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-100"
|
||||
label: "已完成",
|
||||
tone: "success"
|
||||
},
|
||||
error: {
|
||||
label: "失败",
|
||||
className: "bg-red-100 text-red-700",
|
||||
dotClassName: "border-red-500 bg-red-100"
|
||||
tone: "danger"
|
||||
}
|
||||
};
|
||||
|
||||
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-500"
|
||||
tone: "success"
|
||||
},
|
||||
in_progress: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
tone: "info",
|
||||
activity: "live"
|
||||
},
|
||||
pending: {
|
||||
label: "待办",
|
||||
className: "bg-slate-100 text-slate-600",
|
||||
dotClassName: "border-slate-300 bg-white"
|
||||
tone: "neutral"
|
||||
},
|
||||
cancelled: {
|
||||
label: "取消",
|
||||
className: "bg-slate-200 text-slate-500",
|
||||
dotClassName: "border-slate-300 bg-slate-200"
|
||||
tone: "neutral"
|
||||
}
|
||||
};
|
||||
|
||||
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
|
||||
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
|
||||
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
|
||||
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
|
||||
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
|
||||
approved_always: { label: "已始终允许", tone: "success" },
|
||||
approved_once: { label: "已允许一次", tone: "success" },
|
||||
rejected: { label: "已拒绝", tone: "danger" },
|
||||
aborted: { label: "已中止", tone: "neutral" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||
pending: { label: "待确认", tone: "warning" }
|
||||
};
|
||||
|
||||
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
|
||||
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
|
||||
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
|
||||
answered: { label: "已回答", tone: "success" },
|
||||
rejected: { label: "已跳过", tone: "neutral" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||
pending: { label: "待回答", tone: "info" }
|
||||
};
|
||||
|
||||
export function AgentMessageDetails({
|
||||
@@ -198,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
|
||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const listId = useId();
|
||||
const compact = running && !expanded;
|
||||
const visibleProgress = expanded
|
||||
? progress
|
||||
@@ -207,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
|
||||
{running ? (
|
||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||
) : null}
|
||||
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||||
<span className="font-mono tabular-nums text-slate-600">
|
||||
{expanded || !running
|
||||
? progress.length
|
||||
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||||
<span className="px-0.5 text-slate-300">/</span>
|
||||
{progress.length}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
className={cn(
|
||||
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
|
||||
expanded && "text-blue-500"
|
||||
)}
|
||||
aria-controls={listId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
|
||||
data-state={expanded ? "open" : "closed"}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<span>执行进度</span>
|
||||
<span className="flex-1" />
|
||||
<span className="shrink-0 font-normal text-slate-400">
|
||||
{expanded
|
||||
? `全部 ${progress.length} 条`
|
||||
: running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: `共 ${progress.length} 条`}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
id={listId}
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: agentEase }}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
||||
progressStatusMeta[item.status].dotClassName
|
||||
)}
|
||||
animate={
|
||||
item.status === "running"
|
||||
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
||||
: item.status === "error"
|
||||
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
||||
: { opacity: 1, scale: 1 }
|
||||
}
|
||||
transition={
|
||||
item.status === "running"
|
||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
||||
: item.status === "error"
|
||||
? { duration: 0.28, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
<StatusDot
|
||||
tone={progressStatusMeta[item.status].tone}
|
||||
activity={progressStatusMeta[item.status].activity}
|
||||
size="md"
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
key={item.status}
|
||||
data-progress-status={item.status}
|
||||
className={cn(
|
||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
||||
progressStatusMeta[item.status].className
|
||||
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||
item.status === "running"
|
||||
? "text-blue-600"
|
||||
: [
|
||||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||
"text-[var(--status-foreground)]"
|
||||
]
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
item.status === "error"
|
||||
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||
: { opacity: 1, x: 0 }
|
||||
}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={
|
||||
item.status === "error"
|
||||
? { duration: 0.24, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
transition={{ duration: 0.14 }}
|
||||
>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</motion.span>
|
||||
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
|
||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
||||
todoStatusMeta[todo.status].dotClassName
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
|
||||
</SemanticStatusIcon>
|
||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
||||
<StatusBadge
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
>
|
||||
{todoStatusMeta[todo.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -376,7 +385,7 @@ function PermissionList({
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
|
||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
@@ -424,18 +433,22 @@ function PermissionRequestCard({
|
||||
{target}
|
||||
</p>
|
||||
{permission.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||
<StatusBadge
|
||||
tone={permissionStatusMeta[permission.status].tone}
|
||||
activity={permissionStatusMeta[permission.status].activity}
|
||||
className="self-start"
|
||||
>
|
||||
{permissionStatusMeta[permission.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="permission-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
className="mt-2 flex flex-wrap items-center gap-2"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -445,7 +458,7 @@ function PermissionRequestCard({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("once")}
|
||||
>
|
||||
@@ -457,7 +470,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("always")}
|
||||
>
|
||||
@@ -469,7 +482,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
||||
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("reject")}
|
||||
>
|
||||
@@ -547,9 +560,12 @@ function QuestionRequestCard({
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
||||
<StatusBadge
|
||||
tone={questionStatusMeta[request.status].tone}
|
||||
activity={questionStatusMeta[request.status].activity}
|
||||
>
|
||||
{questionStatusMeta[request.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -558,7 +574,7 @@ function QuestionRequestCard({
|
||||
key={`${request.requestId}-${questionIndex}`}
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
|
||||
disabled={!actionable || submitting}
|
||||
onChange={(nextValue) =>
|
||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||
@@ -568,7 +584,7 @@ function QuestionRequestCard({
|
||||
</div>
|
||||
|
||||
{request.error ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
|
||||
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
|
||||
) : null}
|
||||
{request.answers?.length ? (
|
||||
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||
@@ -580,7 +596,7 @@ function QuestionRequestCard({
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="question-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
className="mt-2 flex flex-wrap items-center gap-2"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -590,7 +606,7 @@ function QuestionRequestCard({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
@@ -601,7 +617,7 @@ function QuestionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
disabled={submitting || !onRejectQuestion}
|
||||
onClick={reject}
|
||||
>
|
||||
@@ -617,6 +633,7 @@ function QuestionRequestCard({
|
||||
|
||||
type QuestionDraftAnswer = {
|
||||
selected: string[];
|
||||
customSelected: boolean;
|
||||
custom: string;
|
||||
};
|
||||
|
||||
@@ -633,8 +650,11 @@ function QuestionInput({
|
||||
disabled: boolean;
|
||||
onChange: (value: QuestionDraftAnswer) => void;
|
||||
}) {
|
||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||
const showCustomInput = question.custom || question.options.length === 0;
|
||||
const inputId = useId();
|
||||
const name = `agent-question-${questionIndex}-${inputId}`;
|
||||
const customEnabled = question.custom !== false;
|
||||
const showCustomChoice = customEnabled && question.options.length > 0;
|
||||
const showCustomInput = customEnabled && value.customSelected;
|
||||
|
||||
const toggleOption = (label: string, checked: boolean) => {
|
||||
if (question.multiple) {
|
||||
@@ -646,21 +666,36 @@ function QuestionInput({
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [label] : []
|
||||
selected: checked ? [label] : [],
|
||||
customSelected: false
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCustom = (checked: boolean) => {
|
||||
onChange({
|
||||
...value,
|
||||
selected: question.multiple ? value.selected : [],
|
||||
customSelected: checked
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||
{question.options.length ? (
|
||||
{question.options.length || showCustomChoice ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option) => {
|
||||
const checked = value.selected.includes(option.label);
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||||
checked
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
@@ -679,12 +714,34 @@ function QuestionInput({
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{showCustomChoice ? (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||||
value.customSelected
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
className="h-3.5 w-3.5 accent-blue-600"
|
||||
checked={value.customSelected}
|
||||
disabled={disabled}
|
||||
onChange={(event) => toggleCustom(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="font-semibold text-slate-700">自定义回答</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
aria-label="自定义回答内容"
|
||||
placeholder="输入自定义回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||
@@ -695,16 +752,25 @@ function QuestionInput({
|
||||
}
|
||||
|
||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||
return request.questions.map((question, index) => ({
|
||||
selected: request.answers?.[index] ?? [],
|
||||
custom: ""
|
||||
}));
|
||||
return request.questions.map((question, index) => {
|
||||
const optionLabels = new Set(question.options.map((option) => option.label));
|
||||
const savedAnswers = request.answers?.[index] ?? [];
|
||||
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
|
||||
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
|
||||
const customEnabled = question.custom !== false;
|
||||
|
||||
return {
|
||||
selected,
|
||||
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
|
||||
custom: customAnswers.join(";")
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||
return draftAnswers.map((answer) => {
|
||||
const custom = answer.custom.trim();
|
||||
return custom ? [...answer.selected, custom] : answer.selected;
|
||||
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Crosshair,
|
||||
MapPinned,
|
||||
Radio,
|
||||
Route
|
||||
} from "lucide-react";
|
||||
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type {
|
||||
AgentChatMessage
|
||||
} from "../types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||
import { countPendingAgentInputs } from "../message-state";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
|
||||
export function AgentOperationalBrief({
|
||||
messages,
|
||||
@@ -26,52 +20,71 @@ export function AgentOperationalBrief({
|
||||
|
||||
return (
|
||||
<div className="agent-panel-control rounded-2xl p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
||||
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
||||
<AgentBriefMetric
|
||||
icon={<Radio size={13} aria-hidden="true" />}
|
||||
label="证据"
|
||||
value={brief.evidence}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<Route size={13} aria-hidden="true" />}
|
||||
label="计划"
|
||||
value={brief.plan}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<ClipboardCheck size={13} aria-hidden="true" />}
|
||||
label="确认"
|
||||
value={brief.confirmation}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||
>
|
||||
<MapPinned size={14} aria-hidden="true" />
|
||||
预览影响
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
<Crosshair size={14} aria-hidden="true" />
|
||||
检查证据
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
function AgentBriefMetric({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
</dt>
|
||||
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
|
||||
);
|
||||
}
|
||||
|
||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
||||
function createOperationalBrief(
|
||||
messages: AgentChatMessage[],
|
||||
statusLabel: string,
|
||||
streaming: boolean
|
||||
) {
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user" && message.content.trim());
|
||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
||||
const latestProgress = progressItems.at(-1);
|
||||
const activeTodo =
|
||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||
const pendingConfirmations =
|
||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
||||
const pendingConfirmations = countPendingAgentInputs(messages);
|
||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const plan =
|
||||
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
||||
const stateLabel =
|
||||
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusTone: StatusTone =
|
||||
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
|
||||
|
||||
return {
|
||||
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
|
||||
plan,
|
||||
confirmation,
|
||||
stateLabel,
|
||||
statusDotClassName,
|
||||
statusTone,
|
||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
@@ -282,7 +283,7 @@ function TrustedPanelShell({
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
||||
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
@@ -55,6 +57,7 @@ export type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentConnectionStatus,
|
||||
AgentInteractionToolRef,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
describe("Agent message state", () => {
|
||||
it("counts pending and failed operator inputs from one shared rule", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
permissions: [
|
||||
{ status: "pending" },
|
||||
{ status: "approved_once" }
|
||||
],
|
||||
questions: [
|
||||
{ status: "error" },
|
||||
{ status: "answered" }
|
||||
]
|
||||
}
|
||||
] as AgentChatMessage[];
|
||||
|
||||
expect(countPendingAgentInputs(messages)).toBe(2);
|
||||
expect(hasPendingAgentInput(messages)).toBe(true);
|
||||
expect(hasPendingAgentInput([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
|
||||
return messages.reduce((total, message) => {
|
||||
const pendingPermissions =
|
||||
message.permissions?.filter(
|
||||
(permission) => permission.status === "pending" || permission.status === "error"
|
||||
).length ?? 0;
|
||||
const pendingQuestions =
|
||||
message.questions?.filter(
|
||||
(question) => question.status === "pending" || question.status === "error"
|
||||
).length ?? 0;
|
||||
return total + pendingPermissions + pendingQuestions;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
|
||||
return countPendingAgentInputs(messages) > 0;
|
||||
}
|
||||
@@ -94,6 +94,33 @@ describe("agent session state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an actionable question id when its tool placeholder arrives later", () => {
|
||||
const questions = upsertQuestion(undefined, {
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 100
|
||||
});
|
||||
const withPlaceholder = upsertQuestion(questions, {
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 120
|
||||
});
|
||||
const rejected = applyQuestionResponse(withPlaceholder, {
|
||||
request_id: "question-1",
|
||||
rejected: true
|
||||
});
|
||||
|
||||
expect(withPlaceholder).toHaveLength(1);
|
||||
expect(rejected?.[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
status: "rejected"
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes open assistant state after abort", () => {
|
||||
const message: AgentChatMessage = {
|
||||
id: "assistant-1",
|
||||
|
||||
@@ -183,10 +183,23 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
|
||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
const current = next[index];
|
||||
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
|
||||
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
|
||||
|
||||
next[index] =
|
||||
currentIsActionable && nextIsToolPlaceholder
|
||||
? {
|
||||
...current,
|
||||
sessionId: nextItem.sessionId,
|
||||
questions: nextItem.questions,
|
||||
tool: nextItem.tool ?? current.tool,
|
||||
createdAt: nextItem.createdAt
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
status: current.status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
|
||||
@@ -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: "acrylic-panel text-slate-900",
|
||||
success: "acrylic-panel text-slate-900",
|
||||
warning: "acrylic-panel text-slate-900",
|
||||
error: "acrylic-panel text-slate-900",
|
||||
loading: "acrylic-panel text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "text-blue-600",
|
||||
success: "text-green-600",
|
||||
warning: "text-orange-500",
|
||||
error: "text-red-500",
|
||||
loading: "text-blue-600"
|
||||
};
|
||||
|
||||
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "bg-blue-600",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-orange-500",
|
||||
error: "bg-red-500",
|
||||
loading: "bg-blue-600"
|
||||
};
|
||||
|
||||
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "border-blue-100 bg-blue-50",
|
||||
success: "border-green-100 bg-green-50",
|
||||
warning: "border-orange-100 bg-orange-50",
|
||||
error: "border-red-100 bg-red-50",
|
||||
loading: "border-blue-100 bg-blue-50"
|
||||
};
|
||||
const noticeTones = {
|
||||
info: "info",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
loading: "info"
|
||||
} satisfies Record<MapNoticeTone, StatusTone>;
|
||||
|
||||
export function MapNoticeContent({
|
||||
title,
|
||||
@@ -55,7 +36,11 @@ export function MapNoticeContent({
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||
action={
|
||||
actionLabel && onAction ? (
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
@@ -75,29 +60,31 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
||||
<div
|
||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||
className={cn(
|
||||
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
||||
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
toneClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||
function MapNoticeIcon({
|
||||
tone,
|
||||
icon: Icon
|
||||
}: {
|
||||
tone: MapNoticeTone;
|
||||
icon: ReturnType<typeof getNoticeIcon>;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
iconClassNames[tone],
|
||||
noticeIconSurfaceClassNames[tone]
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={noticeTones[tone]}
|
||||
activity={tone === "loading" ? "loading" : "static"}
|
||||
size="md"
|
||||
className={cn("mt-0.5", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
<Icon size={17} />
|
||||
</SemanticStatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,8 +99,12 @@ function MapNoticeBody({
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||
{title ? (
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
) : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
|
||||
{message}
|
||||
</span>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
@@ -121,13 +112,15 @@ function MapNoticeBody({
|
||||
|
||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function MapToaster() {
|
||||
classNames: {
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
||||
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -44,10 +44,10 @@ export function MapToolbar({
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto p-[3px]",
|
||||
"pointer-events-auto p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
||||
isVertical ? "flex w-12 flex-col" : "inline-flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -67,19 +67,18 @@ export function MapToolbar({
|
||||
disabled={tool.disabled}
|
||||
onClick={tool.onClick}
|
||||
className={cn(
|
||||
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
|
||||
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
tool.active
|
||||
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
|
||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
|
||||
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||
<span className="sr-only">{tool.label}</span>
|
||||
{tool.badge ? (
|
||||
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
<span className="status-tone-danger absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-[var(--status-mark)] px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
{tool.badge}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -37,13 +37,13 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
role="group"
|
||||
aria-label="地图缩放"
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-[46px] flex-col gap-2",
|
||||
"pointer-events-auto flex w-12 flex-col gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col p-[3px]",
|
||||
"flex flex-col p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -65,7 +65,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"p-[3px]",
|
||||
"p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
@@ -99,13 +99,12 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
||||
"grid h-10 w-10 place-items-center border border-transparent",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
"bg-transparent text-slate-700",
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
disabled &&
|
||||
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
|
||||
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
|
||||
@@ -439,7 +439,10 @@ function TickerProgressBar({
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.span
|
||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||
className={cn(
|
||||
"status-tone-info block h-full rounded-full",
|
||||
muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]"
|
||||
)}
|
||||
animate={{ width: progressLabel }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
@@ -452,9 +455,9 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||
const dashOffset = 44 - (44 * progress) / 100;
|
||||
return (
|
||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<span className="status-tone-info grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-[var(--status-border)]" />
|
||||
<motion.circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
@@ -462,13 +465,13 @@ function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="stroke-blue-600"
|
||||
className="stroke-[var(--status-foreground)]"
|
||||
strokeDasharray="44"
|
||||
animate={{ strokeDashoffset: dashOffset }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-[var(--status-mark)]" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||
<section className="material-tone-agent mt-4 rounded border border-violet-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-violet-800">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
showMapNotice
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
@@ -48,21 +53,13 @@ const FEATURE_LAYER_META: Record<
|
||||
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
|
||||
};
|
||||
|
||||
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-50 text-emerald-700 ring-emerald-600/15",
|
||||
inactive: "bg-slate-100 text-slate-600 ring-slate-500/15",
|
||||
warning: "bg-orange-50 text-orange-700 ring-orange-600/15",
|
||||
critical: "bg-red-50 text-red-700 ring-red-600/15",
|
||||
info: "bg-blue-50 text-blue-700 ring-blue-600/15"
|
||||
};
|
||||
|
||||
const BADGE_DOT_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
active: "bg-emerald-500",
|
||||
inactive: "bg-slate-400",
|
||||
warning: "bg-orange-500",
|
||||
critical: "bg-red-500",
|
||||
info: "bg-blue-500"
|
||||
};
|
||||
const badgeTones = {
|
||||
active: "success",
|
||||
inactive: "neutral",
|
||||
warning: "warning",
|
||||
critical: "danger",
|
||||
info: "info"
|
||||
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
|
||||
|
||||
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
@@ -125,10 +122,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
||||
{panelModel.badge ? (
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset", BADGE_CLASS_NAMES[panelModel.badge.tone])}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])} aria-hidden="true" />
|
||||
<StatusBadge
|
||||
tone={badgeTones[panelModel.badge.tone]}
|
||||
icon={<StatusDot tone={badgeTones[panelModel.badge.tone]} />}
|
||||
>
|
||||
{panelModel.badge.label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||
@@ -137,12 +136,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{canCopy ? (
|
||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
|
||||
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
<Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MapDevPanel } from "./map-dev-panel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("MapDevPanel flow controls", () => {
|
||||
it("updates a pipe with its signed velocity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
|
||||
render(
|
||||
<MapDevPanel
|
||||
commands={commands}
|
||||
controllerState={createControllerState()}
|
||||
detailFeature={null}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const pipeId = screen.getByLabelText("管道 ID");
|
||||
const velocity = screen.getByLabelText("有符号流速");
|
||||
await user.clear(pipeId);
|
||||
await user.type(pipeId, "P-9");
|
||||
await user.clear(velocity);
|
||||
await user.type(velocity, "-2.75");
|
||||
await user.click(screen.getByRole("button", { name: "更新该管道方向" }));
|
||||
|
||||
expect(commands.updatePipeFlowCalculation).toHaveBeenCalledWith({
|
||||
pipeId: "P-9",
|
||||
velocity: -2.75
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the current flow direction counts", () => {
|
||||
render(
|
||||
<MapDevPanel
|
||||
commands={createCommands()}
|
||||
controllerState={createControllerState({
|
||||
flowSummary: {
|
||||
total: 4,
|
||||
forward: 2,
|
||||
reverse: 1,
|
||||
stopped: 1,
|
||||
invalid: 0
|
||||
}
|
||||
})}
|
||||
detailFeature={null}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", {
|
||||
name: ""
|
||||
})).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
|
||||
});
|
||||
});
|
||||
|
||||
function createCommands() {
|
||||
return {
|
||||
zoomToFeature: vi.fn(),
|
||||
locateAndHighlight: vi.fn(),
|
||||
highlight: vi.fn(),
|
||||
clearHighlight: vi.fn(),
|
||||
setFlowVisible: vi.fn(),
|
||||
replacePipeFlowCalculations: vi.fn(),
|
||||
updatePipeFlowCalculation: vi.fn(),
|
||||
clearPipeFlowCalculations: vi.fn(),
|
||||
showValueLabel: vi.fn(),
|
||||
clearValueLabel: vi.fn(),
|
||||
applyLayerGroupStyle: vi.fn(),
|
||||
resetLayerGroupStyle: vi.fn(),
|
||||
renderScadaAnalysis: vi.fn(),
|
||||
clearScadaAnalysis: vi.fn(),
|
||||
resetAll: vi.fn()
|
||||
} satisfies WorkbenchMapCommands;
|
||||
}
|
||||
|
||||
function createControllerState(
|
||||
patch: Partial<WorkbenchMapControllerState> = {}
|
||||
): WorkbenchMapControllerState {
|
||||
return {
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: {
|
||||
total: 0,
|
||||
forward: 0,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid: 0
|
||||
},
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
@@ -13,7 +14,10 @@ import type {
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, type AvailableWaterNetworkSourceId } from "../map/sources";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
@@ -33,21 +37,80 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
||||
|
||||
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||
|
||||
const LABEL_PRESETS: Record<
|
||||
LayerGroupId,
|
||||
{
|
||||
property: string;
|
||||
precision: number;
|
||||
unit: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
> = {
|
||||
pipes: {
|
||||
property: "diameter",
|
||||
precision: 0,
|
||||
unit: "mm",
|
||||
options: [
|
||||
{ value: "diameter", label: "管径 diameter" },
|
||||
{ value: "length", label: "长度 length" },
|
||||
{ value: "roughness", label: "粗糙系数 roughness" },
|
||||
{ value: "minor_loss", label: "局部损失 minor_loss" }
|
||||
]
|
||||
},
|
||||
junctions: {
|
||||
property: "elevation",
|
||||
precision: 1,
|
||||
unit: "m",
|
||||
options: [
|
||||
{ value: "elevation", label: "高程 elevation" },
|
||||
{ value: "demand", label: "需水量 demand" }
|
||||
]
|
||||
},
|
||||
valves: {
|
||||
property: "diameter",
|
||||
precision: 0,
|
||||
unit: "mm",
|
||||
options: [
|
||||
{ value: "diameter", label: "管径 diameter" },
|
||||
{ value: "setting", label: "设定值 setting" },
|
||||
{ value: "minor_loss", label: "局部损失 minor_loss" }
|
||||
]
|
||||
},
|
||||
reservoirs: {
|
||||
property: "head",
|
||||
precision: 1,
|
||||
unit: "m",
|
||||
options: [{ value: "head", label: "水头 head" }]
|
||||
}
|
||||
};
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
};
|
||||
|
||||
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
|
||||
export function MapDevPanel({
|
||||
commands,
|
||||
controllerState,
|
||||
detailFeature,
|
||||
onClose
|
||||
}: MapDevPanelProps) {
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [property, setProperty] = useState("diameter");
|
||||
const [flowPipeId, setFlowPipeId] = useState("440681");
|
||||
const [flowVelocity, setFlowVelocity] = useState("-1");
|
||||
const [precision, setPrecision] = useState(0);
|
||||
const [unit, setUnit] = useState("mm");
|
||||
const [groupId, setGroupId] = useState<LayerGroupId>("pipes");
|
||||
const [showValueLabels, setShowValueLabels] = useState(true);
|
||||
const [showFeatureIds, setShowFeatureIds] = useState(false);
|
||||
const [labelSize, setLabelSize] = useState(12);
|
||||
const [labelColor, setLabelColor] = useState<string>(MAP_STYLE_TOKENS.label.primary);
|
||||
const [draft, setDraft] = useState<Record<string, string | number>>({
|
||||
color: MAP_STYLE_TOKENS.supply.pipeMedium,
|
||||
fillColor: MAP_STYLE_TOKENS.supply.junction,
|
||||
@@ -68,26 +131,49 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const numericProperties = useMemo(
|
||||
() => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId
|
||||
? getNumericFeatureProperties(detailFeature.properties)
|
||||
: [],
|
||||
[detailFeature, featureId, sourceId]
|
||||
);
|
||||
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
|
||||
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
|
||||
const activeLabel = controllerState.valueLabels[groupId];
|
||||
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
|
||||
|
||||
function selectStyleGroup(nextGroupId: LayerGroupId) {
|
||||
const preset = LABEL_PRESETS[nextGroupId];
|
||||
const saved = controllerState.valueLabels[nextGroupId];
|
||||
setGroupId(nextGroupId);
|
||||
setProperty(saved?.property ?? preset.property);
|
||||
setPrecision(saved?.precision ?? preset.precision);
|
||||
setUnit(saved?.unit ?? preset.unit);
|
||||
setShowValueLabels(saved?.showValue ?? true);
|
||||
setShowFeatureIds(saved?.showId ?? false);
|
||||
setLabelSize(saved?.textSize ?? 12);
|
||||
setLabelColor(saved?.textColor ?? MAP_STYLE_TOKENS.label.primary);
|
||||
}
|
||||
|
||||
function useSelectedFeature() {
|
||||
if (!detailFeature) return;
|
||||
if (AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)) {
|
||||
if (
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
|
||||
detailFeature.layer as AvailableWaterNetworkSourceId
|
||||
)
|
||||
) {
|
||||
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
|
||||
}
|
||||
setFeatureId(detailFeature.id);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
selectStyleGroup(nextGroupId);
|
||||
const allowedProperties = new Set(
|
||||
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
|
||||
);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
|
||||
([name]) => allowedProperties.has(name)
|
||||
);
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
function applyLayerStyle() {
|
||||
let patch: LayerGroupStylePatch;
|
||||
if (geometry === "line") {
|
||||
patch = {
|
||||
@@ -103,15 +189,44 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
opacity: Number(draft.opacity),
|
||||
radiusMultiplier: Number(draft.radiusMultiplier),
|
||||
strokeMultiplier: Number(draft.strokeMultiplier),
|
||||
iconMultiplier: Number(draft.iconMultiplier)
|
||||
};
|
||||
}
|
||||
commands.applyLayerGroupStyle(groupId, patch);
|
||||
}
|
||||
|
||||
function applyLabels() {
|
||||
if (showValueLabels || showFeatureIds) {
|
||||
void commands.showValueLabel({
|
||||
sourceId: groupId,
|
||||
property,
|
||||
precision,
|
||||
unit,
|
||||
showValue: showValueLabels,
|
||||
showId: showFeatureIds,
|
||||
textSize: labelSize,
|
||||
textColor: labelColor
|
||||
});
|
||||
} else {
|
||||
commands.clearValueLabel(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlowCalculation() {
|
||||
commands.updatePipeFlowCalculation({
|
||||
pipeId: flowPipeId.trim(),
|
||||
velocity: Number(flowVelocity)
|
||||
});
|
||||
}
|
||||
|
||||
const flowSummary = controllerState.flowSummary;
|
||||
const validFlowCalculation =
|
||||
flowPipeId.trim().length > 0 && Number.isFinite(Number(flowVelocity));
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
|
||||
@@ -119,14 +234,23 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
|
||||
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label="关闭 Dev Panel"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
|
||||
<div
|
||||
role="alert"
|
||||
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -134,101 +258,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Field label="业务源">
|
||||
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as AvailableWaterNetworkSourceId)} className={inputClassName}>
|
||||
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(event) => {
|
||||
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
className={inputClassName}
|
||||
>
|
||||
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GROUP_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Feature ID">
|
||||
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
|
||||
<input
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到 Feature
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.target ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
|
||||
<PanelSection
|
||||
title="全网水流"
|
||||
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
|
||||
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
|
||||
<Field label="数值属性">
|
||||
{numericProperties.length ? (
|
||||
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
|
||||
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
|
||||
</select>
|
||||
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
|
||||
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
|
||||
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
|
||||
{flowSummary.reverse} · 停止 {flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后自动显示全网 mock 流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
|
||||
<PanelSection
|
||||
title="样式编辑"
|
||||
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
|
||||
>
|
||||
<Field label="业务图层组">
|
||||
<div className="relative">
|
||||
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
|
||||
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={groupId}
|
||||
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
|
||||
className={cn(inputClassName, "appearance-none pr-8")}
|
||||
>
|
||||
{STYLE_GROUP_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GROUP_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
|
||||
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<Field label="线型">
|
||||
<select
|
||||
value={draft.dash}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, dash: event.target.value }))
|
||||
}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="original">原始</option>
|
||||
<option value="solid">实线</option>
|
||||
<option value="dashed">虚线</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
|
||||
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
|
||||
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
|
||||
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
|
||||
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
|
||||
作用于当前图层所有可见 Feature,自动避让重叠。
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
|
||||
MVT 原生
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<Field label="数值属性">
|
||||
<select
|
||||
value={property}
|
||||
onChange={(event) => setProperty(event.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
{LABEL_PRESETS[groupId].options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/70 p-3">
|
||||
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
重置全部
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
|
||||
function PanelSection({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
|
||||
function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
|
||||
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
|
||||
function ColorField({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-600">{value}</span>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
function RangeField({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={`${label} · ${value}`}>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-6 w-full accent-blue-600"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
export type MobileWorkbenchSheetSnap = "half" | "full";
|
||||
|
||||
export function getMobileWorkbenchSheetHeight(
|
||||
snap: MobileWorkbenchSheetSnap,
|
||||
viewportHeight: number
|
||||
) {
|
||||
if (snap === "half") {
|
||||
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio),
|
||||
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
describe("mobile workbench sheet heights", () => {
|
||||
it("keeps the two deterministic snap heights", () => {
|
||||
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
|
||||
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
|
||||
});
|
||||
|
||||
it("always leaves the configured map strip visible at full height", () => {
|
||||
const viewportHeight = 667;
|
||||
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
|
||||
|
||||
expect(viewportHeight - sheetHeight).toBe(68);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
getMobileWorkbenchSheetHeight,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./mobile-workbench-sheet-layout";
|
||||
|
||||
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
type MobileWorkbenchSheetProps = {
|
||||
label: string;
|
||||
snap: MobileWorkbenchSheetSnap;
|
||||
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
||||
|
||||
export function MobileWorkbenchSheet({
|
||||
label,
|
||||
snap,
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(
|
||||
typeof window === "undefined" ? 844 : window.innerHeight
|
||||
);
|
||||
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
y: event.clientY,
|
||||
height: restingHeight
|
||||
};
|
||||
draggedRef.current = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = start.y - event.clientY;
|
||||
if (Math.abs(delta) > 4) {
|
||||
draggedRef.current = true;
|
||||
}
|
||||
|
||||
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
|
||||
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
||||
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
||||
if (!pointerStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
||||
pointerStartRef.current = null;
|
||||
setDragHeight(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (!draggedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (draggedRef.current) {
|
||||
draggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = SNAP_ORDER.indexOf(snap);
|
||||
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
onSnapChange("half");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
onSnapChange("full");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
data-snap={snap}
|
||||
className={cn(
|
||||
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
||||
className
|
||||
)}
|
||||
style={{ height: dragHeight ?? restingHeight }}
|
||||
>
|
||||
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdjacentSnap(
|
||||
current: MobileWorkbenchSheetSnap,
|
||||
direction: -1 | 1
|
||||
): MobileWorkbenchSheetSnap {
|
||||
const currentIndex = SNAP_ORDER.indexOf(current);
|
||||
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
||||
return SNAP_ORDER[nextIndex] ?? current;
|
||||
}
|
||||
|
||||
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
||||
return SNAP_ORDER.reduce((closest, candidate) => {
|
||||
const candidateDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
||||
);
|
||||
const closestDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
||||
);
|
||||
return candidateDistance < closestDistance ? candidate : closest;
|
||||
}, "half");
|
||||
}
|
||||
|
||||
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
||||
if (snap === "half") {
|
||||
return "半屏档";
|
||||
}
|
||||
|
||||
return "全屏档";
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
getRunningWorkflowDefinition,
|
||||
@@ -37,25 +42,26 @@ import {
|
||||
formatDuration,
|
||||
formatTime,
|
||||
formatTimeRange,
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getRiskLabel,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStagePillClassName,
|
||||
statusClassNames,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
statusLabels
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||
|
||||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
||||
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||
|
||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
attention: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
critical: "border-red-200 bg-red-50 text-red-700"
|
||||
};
|
||||
const riskTones = {
|
||||
normal: "success",
|
||||
attention: "warning",
|
||||
critical: "danger"
|
||||
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
||||
|
||||
type IncidentOperation = {
|
||||
target: string;
|
||||
@@ -86,6 +92,7 @@ type ExecutionStep = {
|
||||
title: string;
|
||||
description: string;
|
||||
status: ExecutionStepStatus;
|
||||
statusLabel?: string;
|
||||
meta?: ExecutionStepMeta[];
|
||||
};
|
||||
|
||||
@@ -105,15 +112,21 @@ export function StatusPill({
|
||||
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
STATUS_PILL_CLASS_NAME,
|
||||
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
|
||||
className
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageTone(currentStage?.id)
|
||||
: getStatusTone(condition.status)
|
||||
}
|
||||
activity={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageActivity(currentStage?.id)
|
||||
: getStatusActivity(condition.status)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="min-h-0"
|
||||
className="relative h-full min-h-0"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
@@ -198,7 +211,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={ShieldAlert}
|
||||
title="异常判断"
|
||||
iconClassName="text-red-600"
|
||||
iconClassName="status-tone-danger text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -221,7 +234,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={Lightbulb}
|
||||
title="关注说明"
|
||||
iconClassName="text-orange-600"
|
||||
iconClassName="status-tone-warning text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -274,7 +287,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
|
||||
icon={CalendarClock}
|
||||
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
|
||||
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
|
||||
actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"}
|
||||
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
|
||||
actionDisabled
|
||||
actionTitle="工单流程由调度和现场班组流转"
|
||||
/>
|
||||
@@ -331,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
<section
|
||||
className={cn(
|
||||
NEUTRAL_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -343,9 +366,7 @@ type DetailShellProps = {
|
||||
|
||||
function DetailShell({ children }: DetailShellProps) {
|
||||
return (
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,7 +541,9 @@ function IncidentWorkOrderPanel({
|
||||
className={cn(
|
||||
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
||||
"rounded-xl",
|
||||
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
||||
submitted
|
||||
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
||||
@@ -543,9 +566,9 @@ function IncidentOperationList({
|
||||
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionEyebrow icon={Wrench} label={operation.action} />
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
|
||||
<StatusBadge tone={submitted ? "success" : "neutral"}>
|
||||
{submitted ? "已通知" : operation.elementType}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
||||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||||
@@ -645,10 +668,9 @@ function ConditionStatusReport({
|
||||
|
||||
function LifecyclePill() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
|
||||
<CheckCircle2 size={12} aria-hidden="true" />
|
||||
<span>完成</span>
|
||||
</span>
|
||||
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
|
||||
完成
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -657,23 +679,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
|
||||
const label = getRiskLabel(riskLevel);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
|
||||
getRiskIndicatorClassName(riskLevel)
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={riskTones[riskLevel]}
|
||||
icon={<Icon size={12} />}
|
||||
aria-label={`风险等级:${label}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
return riskIndicatorClassNames[riskLevel];
|
||||
}
|
||||
|
||||
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={REPORT_META_TILE_CLASS_NAME}>
|
||||
@@ -709,7 +724,7 @@ function ReportSection({
|
||||
|
||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
return (
|
||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||
@@ -720,9 +735,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
|
||||
<StatusBadge tone={riskTones[kpi.status]}>
|
||||
{getRiskLabel(kpi.status)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
||||
{kpi.threshold || kpi.baseline ? (
|
||||
@@ -735,18 +750,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
if (riskLevel === "critical") {
|
||||
return "border-red-200/80";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "border-orange-200/80";
|
||||
}
|
||||
|
||||
return "border-slate-200/70";
|
||||
}
|
||||
|
||||
function DecisionActionStrip({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -806,64 +809,36 @@ function SectionEyebrow({
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
|
||||
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
|
||||
if (tone === "completed") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (tone === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
if (tone === "active") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||||
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
|
||||
if (status === "done") {
|
||||
return "bg-green-500 text-white";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "bg-blue-600 text-white";
|
||||
return "info";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "bg-orange-500 text-white";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "bg-slate-100 text-slate-400";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||||
if (status === "current") {
|
||||
return "surface-reading border-blue-100";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "surface-reading border-orange-100";
|
||||
}
|
||||
|
||||
return "surface-reading border-slate-200/70";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||||
if (status === "done") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-white text-slate-500";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
||||
@@ -931,7 +906,6 @@ function RunningWorkflowPanel({
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={Loader2}
|
||||
title={workflow.title}
|
||||
badge="执行中"
|
||||
badgeTone="active"
|
||||
@@ -987,7 +961,7 @@ function TaskExecutionPanel({
|
||||
badgeTone = "pending",
|
||||
children
|
||||
}: {
|
||||
icon: typeof Bot;
|
||||
icon?: typeof Bot;
|
||||
title: string;
|
||||
badge: string;
|
||||
summary?: string;
|
||||
@@ -1000,10 +974,17 @@ function TaskExecutionPanel({
|
||||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{Icon ? (
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{title}</span>
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={getTaskExecutionBadgeTone(badgeTone)}
|
||||
activity={badgeTone === "active" ? "live" : "static"}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||||
{meta?.length ? (
|
||||
@@ -1042,29 +1023,32 @@ function TaskExecutionStepRow({
|
||||
{!isLast ? (
|
||||
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
|
||||
<SemanticStatusIcon
|
||||
tone={getTaskExecutionStepTone(step.status)}
|
||||
activity={step.status === "current" ? "loading" : "static"}
|
||||
shape="circle"
|
||||
size="md"
|
||||
className="relative z-10 ring-4 ring-white"
|
||||
>
|
||||
{step.status === "done" ? (
|
||||
<CheckCircle2 size={14} aria-hidden="true" />
|
||||
<CheckCircle2 size={14} />
|
||||
) : step.status === "current" ? (
|
||||
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
|
||||
<Loader2 size={14} />
|
||||
) : step.status === "confirmation" ? (
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<AlertTriangle size={14} />
|
||||
) : (
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<Clock3 size={14} />
|
||||
)}
|
||||
</span>
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border px-2.5 py-2",
|
||||
getTaskExecutionStepSurfaceClassName(step.status)
|
||||
)}
|
||||
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
|
||||
{getTaskExecutionStepStatusLabel(step.status)}
|
||||
</span>
|
||||
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
|
||||
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
{step.meta?.length ? (
|
||||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||||
@@ -1085,23 +1069,28 @@ function TaskExecutionStepRow({
|
||||
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||||
const duration = formatDuration(condition.durationMinutes, condition.status);
|
||||
const currentStage = getWorkOrderCurrentStage(condition);
|
||||
const steps = condition.stages.map((stage) => ({
|
||||
const steps = condition.stages.map((stage) => {
|
||||
const awaitingReply = stage.id === "reply" && stage.status === "current";
|
||||
|
||||
return {
|
||||
id: stage.id,
|
||||
title: stage.title,
|
||||
status: stage.status,
|
||||
status: awaitingReply ? ("confirmation" as const) : stage.status,
|
||||
statusLabel: awaitingReply ? "待复令" : undefined,
|
||||
description: stage.description,
|
||||
meta: [
|
||||
{ label: "责任人", value: stage.owner },
|
||||
{ label: "时间", value: stage.timeLabel }
|
||||
]
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={ClipboardList}
|
||||
title="任务执行"
|
||||
badge={currentStage ? currentStage.title : condition.code}
|
||||
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
|
||||
badgeTone={currentStage?.id === "reply" ? "confirmation" : currentStage?.id === "execution" ? "active" : "pending"}
|
||||
summary={condition.detail ?? condition.summary}
|
||||
meta={[
|
||||
{ label: "来源", value: condition.source },
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
describe("scheduled condition status presentation", () => {
|
||||
it("maps lifecycle states to the shared semantic language", () => {
|
||||
expect({
|
||||
pending: getStatusTone("pending"),
|
||||
running: getStatusTone("running"),
|
||||
awaitingReply: getStatusTone("awaiting_reply"),
|
||||
completed: getStatusTone("completed"),
|
||||
warning: getStatusTone("warning"),
|
||||
error: getStatusTone("error")
|
||||
}).toEqual({
|
||||
pending: "neutral",
|
||||
running: "info",
|
||||
awaitingReply: "warning",
|
||||
completed: "success",
|
||||
warning: "warning",
|
||||
error: "danger"
|
||||
});
|
||||
expect(getStatusActivity("running")).toBe("loading");
|
||||
expect(getStatusActivity("awaiting_reply")).toBe("static");
|
||||
});
|
||||
|
||||
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
|
||||
expect({
|
||||
dispatch: getWorkOrderStageTone("dispatch"),
|
||||
execution: getWorkOrderStageTone("execution"),
|
||||
reply: getWorkOrderStageTone("reply")
|
||||
}).toEqual({
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
});
|
||||
expect(getWorkOrderStageActivity("execution")).toBe("loading");
|
||||
});
|
||||
});
|
||||
@@ -5,51 +5,75 @@ import {
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionItemStatus,
|
||||
ScheduledWorkOrderItem,
|
||||
ScheduledWorkOrderStage
|
||||
} from "../types";
|
||||
|
||||
export const CONDITION_FILTER_ALL = "__all__";
|
||||
|
||||
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
awaiting_reply: "待复令",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
error: "border-red-100 bg-red-50 text-red-700",
|
||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
};
|
||||
const statusTones = {
|
||||
completed: "success",
|
||||
running: "info",
|
||||
awaiting_reply: "warning",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
pending: "neutral"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
|
||||
|
||||
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
||||
const statusActivities = {
|
||||
completed: "static",
|
||||
running: "loading",
|
||||
awaiting_reply: "static",
|
||||
warning: "static",
|
||||
error: "static",
|
||||
pending: "static"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
|
||||
|
||||
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
running: Loader2,
|
||||
awaiting_reply: Clock3,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
warning: "bg-orange-50 text-orange-700",
|
||||
error: "bg-red-50 text-red-700",
|
||||
pending: "bg-slate-100 text-slate-600"
|
||||
};
|
||||
const workOrderStageTones = {
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
||||
const workOrderStageActivities = {
|
||||
dispatch: "static",
|
||||
execution: "loading",
|
||||
reply: "static"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
|
||||
"running",
|
||||
"awaiting_reply",
|
||||
"warning",
|
||||
"error",
|
||||
"completed",
|
||||
"pending"
|
||||
];
|
||||
|
||||
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
|
||||
|
||||
export type ConditionTaskFilterOption = {
|
||||
value: ConditionTaskFilterValue;
|
||||
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
|
||||
};
|
||||
|
||||
export type ConditionStatusFilterOption = {
|
||||
value: ScheduledConditionStatus;
|
||||
value: ScheduledConditionItemStatus;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
|
||||
}
|
||||
|
||||
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
||||
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
|
||||
if (status === "running") {
|
||||
return "执行中";
|
||||
}
|
||||
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
||||
export function getStatusIcon(status: ScheduledConditionItemStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
||||
return statusIconClassNames[status];
|
||||
export function getStatusTone(status: ScheduledConditionItemStatus) {
|
||||
return statusTones[status];
|
||||
}
|
||||
|
||||
export function getStatusActivity(status: ScheduledConditionItemStatus) {
|
||||
return statusActivities[status];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
X,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
|
||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||
import {
|
||||
CONDITION_FILTER_ALL,
|
||||
@@ -27,8 +29,12 @@ import {
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusActivity,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
getStatusTone,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
@@ -36,10 +42,18 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
|
||||
"surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
"surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
presentation?: ScheduledConditionFeedPresentation;
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
@@ -50,6 +64,7 @@ type ScheduledConditionFeedProps = {
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onClose?: () => void;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -60,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
presentation = "desktop-dock",
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
@@ -70,13 +86,19 @@ export function ScheduledConditionFeed({
|
||||
onFocusRequestHandled,
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onClose,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
@@ -89,10 +111,16 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
() =>
|
||||
conditions.filter(
|
||||
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
|
||||
),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const statusFilterOptions = useMemo(
|
||||
() => createStatusFilterOptions(taskFilteredConditions),
|
||||
[taskFilteredConditions]
|
||||
);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
@@ -101,7 +129,10 @@ export function ScheduledConditionFeed({
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(filteredConditions),
|
||||
[filteredConditions]
|
||||
);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
@@ -170,7 +201,9 @@ export function ScheduledConditionFeed({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
const targetCondition = allTimelineConditions.find(
|
||||
(condition) => condition.id === focusConditionId
|
||||
);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
@@ -180,11 +213,20 @@ export function ScheduledConditionFeed({
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
}, [
|
||||
allTimelineConditions,
|
||||
focusConditionId,
|
||||
focusRequestId,
|
||||
onFocusRequestHandled,
|
||||
updateExpanded,
|
||||
updateSelectedConditionId
|
||||
]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
@@ -226,7 +268,8 @@ export function ScheduledConditionFeed({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
message:
|
||||
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -240,15 +283,39 @@ export function ScheduledConditionFeed({
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -271,18 +338,39 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
{mobileSheet && onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭工况任务"
|
||||
title="关闭工况任务"
|
||||
onClick={onClose}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
mobileSheet
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -295,9 +383,15 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
presentation={presentation}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
@@ -305,7 +399,7 @@ export function ScheduledConditionFeed({
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
@@ -325,6 +419,7 @@ export function ScheduledConditionFeed({
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
presentation: ScheduledConditionFeedPresentation;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
@@ -344,6 +439,7 @@ type ConditionTimelinePanelProps = {
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
presentation,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
@@ -365,9 +461,14 @@ function ConditionTimelinePanel({
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
data-testid="scheduled-condition-timeline"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -385,10 +486,15 @@ function ConditionTimelinePanel({
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionGroupHeader
|
||||
icon={ClipboardList}
|
||||
title="预约任务"
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
selectedConditionId={selectedConditionId}
|
||||
@@ -397,7 +503,11 @@ function ConditionTimelinePanel({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<ConditionGroupHeader
|
||||
icon={History}
|
||||
title="最近工况"
|
||||
count={loading ? 0 : recentConditions.length}
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
@@ -410,7 +520,11 @@ function ConditionTimelinePanel({
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
description={
|
||||
filterActive
|
||||
? "调整任务或状态筛选后查看其他工况记录。"
|
||||
: "新的工况任务完成后会出现在这里。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,7 +560,7 @@ function ConditionFilterBar({
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
@@ -477,7 +591,7 @@ function ConditionFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
@@ -519,10 +633,15 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
<span
|
||||
className="block truncate font-semibold text-slate-800"
|
||||
title={selectedOption?.label}
|
||||
>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">
|
||||
{selectedOption?.count ?? 0} 条
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
@@ -536,7 +655,10 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
sideOffset={6}
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
@@ -582,7 +704,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
@@ -605,10 +730,7 @@ function ConditionDetailSkeleton() {
|
||||
return (
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
className="relative min-h-[420px] overflow-hidden px-4 py-3"
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
@@ -626,7 +748,10 @@ function ConditionDetailSkeleton() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<div
|
||||
key={index}
|
||||
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
|
||||
>
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
@@ -640,8 +765,18 @@ function ConditionDetailSkeleton() {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
<section
|
||||
className={cn(
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -653,9 +788,19 @@ type ConditionGroupHeaderProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
function ConditionGroupHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
compact = false
|
||||
}: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
|
||||
compact ? "mb-1" : "mb-2"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
@@ -694,9 +839,21 @@ type ConditionTimelineRowProps = {
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
function ConditionTimelineRow({
|
||||
condition,
|
||||
selected,
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const Icon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
|
||||
const tone = isWorkOrder
|
||||
? getWorkOrderStageTone(workOrderStage)
|
||||
: getStatusTone(condition.status);
|
||||
const activity = isWorkOrder
|
||||
? getWorkOrderStageActivity(workOrderStage)
|
||||
: getStatusActivity(condition.status);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -705,44 +862,48 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
selected
|
||||
? "surface-reading border-blue-100 text-blue-900"
|
||||
: "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
|
||||
{formatTime(condition.scheduledAt)}
|
||||
</span>
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
<SemanticStatusIcon
|
||||
tone={tone}
|
||||
activity={activity}
|
||||
shape="circle"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
||||
selected
|
||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
||||
: isWorkOrder
|
||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
||||
: getStatusIconClassName(condition.status)
|
||||
"relative z-10 ring-4 ring-white",
|
||||
selected && "group-focus-visible:ring-blue-100"
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
<Icon size={12} />
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
selected ? "text-blue-900" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
<span className="block truncate text-xs font-semibold leading-4">
|
||||
{condition.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||
{condition.summary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { ComponentProps } from "react";
|
||||
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
@@ -14,52 +10,53 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
mobileOpen: boolean;
|
||||
mobilePanelCollapsing: boolean;
|
||||
conditionExpanded: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
onCollapsePanel: () => void;
|
||||
onCloseMobilePanel: () => void;
|
||||
onExpandPanel: () => void;
|
||||
onOpenMobilePanel: () => void;
|
||||
onWidthCommit?: (width: number) => void;
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
conditionExpanded,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
onCollapsePanel,
|
||||
onCloseMobilePanel,
|
||||
onExpandPanel,
|
||||
onOpenMobilePanel
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth);
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||
|
||||
useEffect(() => {
|
||||
onWidthCommit?.(committedWidth);
|
||||
}, [committedWidth, onWidthCommit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
||||
style={{ width: resize.width ?? defaultWidth }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-floating"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
@@ -83,36 +80,5 @@ export function WorkbenchAgentPanels({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileOpen ? (
|
||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={mobilePanelCollapsing}
|
||||
onCollapse={onCloseMobilePanel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={onOpenMobilePanel}
|
||||
className={cn(
|
||||
"grid h-12 w-12 place-items-center text-violet-700",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
headerControlIconClassName
|
||||
@@ -47,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const scenarioStatusTones = {
|
||||
active: "info",
|
||||
draft: "neutral",
|
||||
review: "warning"
|
||||
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
@@ -112,9 +125,9 @@ export function ScenarioMenu({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
@@ -161,29 +174,38 @@ export function AlertMenu({
|
||||
className={cn(
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
||||
compact && "h-10 w-10 justify-center px-0"
|
||||
hasAlerts && !compact &&
|
||||
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
|
||||
compact && compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts &&
|
||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
||||
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||
{compact ? (
|
||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
|
||||
!hasAlerts && "border-transparent! bg-transparent!"
|
||||
)}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -199,7 +221,7 @@ export function AlertMenu({
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
||||
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
@@ -207,20 +229,22 @@ export function AlertMenu({
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
||||
<StatusDot tone="danger" size="md" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
<CheckCircle2 size={16} />
|
||||
</SemanticStatusIcon>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||
</div>
|
||||
@@ -274,7 +298,11 @@ export function UserMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
className={cn(
|
||||
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||
headerControlButtonClassName,
|
||||
compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label="打开用户菜单"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
@@ -316,15 +344,13 @@ function AlertQueueSummary({
|
||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={hasAlerts ? "danger" : "success"}
|
||||
size="md"
|
||||
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
|
||||
</SemanticStatusIcon>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
@@ -332,17 +358,12 @@ function AlertQueueSummary({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
||||
)}
|
||||
>
|
||||
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
||||
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
|
||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,9 +448,9 @@ function MenuHeader({
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
|
||||
</div>
|
||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -469,7 +490,7 @@ function MenuAction({
|
||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
||||
tone === "warning" && "status-icon status-tone-warning",
|
||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
export const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
cn(
|
||||
buttonInteractionVariants({ tone: "neutral" }),
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
|
||||
);
|
||||
|
||||
export const compactHeaderControlButtonClassName =
|
||||
"h-10 w-10 justify-center px-0";
|
||||
|
||||
export const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
FlaskConical,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
@@ -77,7 +75,8 @@ export function WorkbenchTopBar({
|
||||
onExportConfig
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const activeScenario =
|
||||
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
@@ -85,11 +84,18 @@ export function WorkbenchTopBar({
|
||||
return (
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||
供水管网智能调度系统
|
||||
</h1>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,19 +138,24 @@ export function WorkbenchTopBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{devPanelEnabled ? <div className="hidden lg:block">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
data-selection-tone="soft"
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
||||
<span className={headerControlIconClassName}>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
@@ -191,20 +202,12 @@ function TaskTickerToggle({
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -212,30 +215,18 @@ function TaskTickerToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
@@ -243,12 +234,22 @@ function ConditionFeedToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
function HeaderReadout({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,4 +28,37 @@ describe("createOperationalScheduledConditions", () => {
|
||||
expect(currentCondition?.status).toBe("running");
|
||||
expect(currentCondition?.durationMinutes).toBe(5);
|
||||
});
|
||||
|
||||
it("labels the active work-order stage as executing", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
|
||||
const executingWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some(
|
||||
(stage) => stage.id === "execution" && stage.status === "current"
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
executingWorkOrder?.kind === "work_order"
|
||||
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
|
||||
: undefined
|
||||
).toBe("执行中");
|
||||
});
|
||||
|
||||
it("moves completed field work into a waiting-for-reply state", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
|
||||
const awaitingReplyWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
|
||||
);
|
||||
|
||||
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
|
||||
expect(
|
||||
awaitingReplyWorkOrder?.kind === "work_order"
|
||||
? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title
|
||||
: undefined
|
||||
).toBe("待复令");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
ScheduledConditionReport,
|
||||
ScheduledConditionRiskLevel,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionTaskId
|
||||
ScheduledConditionTaskId,
|
||||
ScheduledWorkOrderStatus
|
||||
} from "../types";
|
||||
|
||||
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
|
||||
@@ -16,6 +17,7 @@ const MODEL_NAME = "DrainFlow Realtime Simulator";
|
||||
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
|
||||
|
||||
type ConditionTaskId = ScheduledConditionTaskId;
|
||||
type WorkOrderPhase = "dispatch" | "execution" | "reply";
|
||||
|
||||
type ConditionTaskDefinition = {
|
||||
id: ConditionTaskId;
|
||||
@@ -332,6 +334,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
.map((workOrder) => {
|
||||
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
|
||||
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
|
||||
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
|
||||
|
||||
return {
|
||||
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
|
||||
@@ -340,7 +343,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
|
||||
title: workOrder.title,
|
||||
summary: workOrder.summary,
|
||||
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
|
||||
status: getWorkOrderStatus(phase),
|
||||
riskLevel: workOrder.riskLevel,
|
||||
updatedAt: now.getTime(),
|
||||
detail: workOrder.detail,
|
||||
@@ -352,7 +355,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
assignee: workOrder.assignee,
|
||||
priority: workOrder.priority,
|
||||
replyWindowMinutes: workOrder.replyWindowMinutes,
|
||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
|
||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
|
||||
replyRequirements:
|
||||
currentMinutesOfDay > replyDeadlineMinute
|
||||
? []
|
||||
@@ -385,11 +388,38 @@ function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMin
|
||||
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
|
||||
}
|
||||
|
||||
function getWorkOrderPhase(
|
||||
workOrder: ManualWorkOrderDefinition,
|
||||
currentMinutesOfDay: number
|
||||
): WorkOrderPhase {
|
||||
if (currentMinutesOfDay < workOrder.startMinute) {
|
||||
return "dispatch";
|
||||
}
|
||||
|
||||
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
|
||||
return "execution";
|
||||
}
|
||||
|
||||
return "reply";
|
||||
}
|
||||
|
||||
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
|
||||
if (phase === "dispatch") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (phase === "execution") {
|
||||
return "running";
|
||||
}
|
||||
|
||||
return "awaiting_reply";
|
||||
}
|
||||
|
||||
function createWorkOrderStages(
|
||||
workOrder: ManualWorkOrderDefinition,
|
||||
now: Date,
|
||||
scheduledAtDate: Date,
|
||||
currentMinutesOfDay: number
|
||||
phase: WorkOrderPhase
|
||||
) {
|
||||
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
|
||||
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
|
||||
@@ -397,37 +427,32 @@ function createWorkOrderStages(
|
||||
now,
|
||||
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
|
||||
);
|
||||
const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "dispatch" as const,
|
||||
title: "派发",
|
||||
status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const),
|
||||
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
|
||||
owner: workOrder.dispatcher,
|
||||
timeLabel: formatClock(dispatchAt),
|
||||
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
|
||||
},
|
||||
{
|
||||
id: "execution" as const,
|
||||
title: "执行",
|
||||
title: "执行中",
|
||||
status:
|
||||
currentMinutesOfDay >= executionEndMinute
|
||||
? ("done" as const)
|
||||
: currentMinutesOfDay >= workOrder.startMinute
|
||||
phase === "dispatch"
|
||||
? ("pending" as const)
|
||||
: phase === "execution"
|
||||
? ("current" as const)
|
||||
: ("pending" as const),
|
||||
: ("done" as const),
|
||||
owner: workOrder.assignee,
|
||||
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
|
||||
description: workOrder.summary
|
||||
},
|
||||
{
|
||||
id: "reply" as const,
|
||||
title: "复令",
|
||||
status:
|
||||
currentMinutesOfDay >= executionEndMinute
|
||||
? ("current" as const)
|
||||
: ("pending" as const),
|
||||
title: "待复令",
|
||||
status: phase === "reply" ? ("current" as const) : ("pending" as const),
|
||||
owner: `${workOrder.assignee} → 调度中心`,
|
||||
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
|
||||
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
|
||||
|
||||
@@ -9,27 +9,44 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchViewportLayout
|
||||
getAgentPanelDefaultWidth,
|
||||
getAgentPanelMaxWidth
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
export function useAgentPanelResize(viewportWidth: number) {
|
||||
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const resizeLeftRef = useRef(0);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const widthRef = useRef<number | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth((currentWidth) =>
|
||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
|
||||
);
|
||||
}, [viewportWidth]);
|
||||
if (widthRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resize = useCallback((clientX: number) => {
|
||||
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
|
||||
}, []);
|
||||
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
setCommittedWidth(nextWidth);
|
||||
}, [conditionExpanded, viewportWidth]);
|
||||
|
||||
const resize = useCallback(
|
||||
(clientX: number) => {
|
||||
const nextWidth = clampAgentPanelWidth(
|
||||
clientX - resizeLeftRef.current,
|
||||
window.innerWidth,
|
||||
conditionExpanded
|
||||
);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -48,6 +65,7 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
const stopResizing = () => {
|
||||
cleanupRef.current?.();
|
||||
setResizing(false);
|
||||
setCommittedWidth(widthRef.current);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
@@ -64,7 +82,8 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
[resize]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
@@ -74,18 +93,24 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
}, []);
|
||||
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
|
||||
widthRef.current = clampedWidth;
|
||||
setWidth(clampedWidth);
|
||||
setCommittedWidth(clampedWidth);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
return {
|
||||
committedWidth,
|
||||
handleKeyDown,
|
||||
handlePointerDown,
|
||||
panelRef,
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
createAgentApiClient,
|
||||
fetchAgentBootstrap,
|
||||
fetchAgentSessions,
|
||||
hasPendingAgentInput,
|
||||
isUiEnvelopeAllowed,
|
||||
parseUiEnvelopePayload,
|
||||
type AgentApprovalMode,
|
||||
type AgentChatMessage,
|
||||
type AgentConnectionStatus,
|
||||
type AgentModelOption,
|
||||
type AgentPermissionReply,
|
||||
type AgentPermissionRequest,
|
||||
@@ -45,8 +47,6 @@ import {
|
||||
|
||||
const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
|
||||
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
||||
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||
@@ -71,7 +71,8 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<AgentConnectionStatus>("connecting");
|
||||
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
@@ -140,6 +141,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
|
||||
const handleDataPart = useCallback(
|
||||
(part: AgentDataPart) => {
|
||||
setConnectionStatus("online");
|
||||
const eventSessionId = getDataString(part.data, "session_id");
|
||||
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
@@ -226,7 +228,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||
onError: (error) => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setRuntimeAvailability("failed");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
@@ -237,6 +239,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
},
|
||||
onFinish: () => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setConnectionStatus("online");
|
||||
setStatusLabel("Agent 分析完成");
|
||||
}
|
||||
});
|
||||
@@ -303,7 +306,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
setModelOptions(backendConnection.models);
|
||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||
if (chatRef.current.status === "ready") {
|
||||
setRuntimeAvailability("connected");
|
||||
setConnectionStatus("online");
|
||||
setStatusLabel("准备就绪");
|
||||
}
|
||||
}, [backendConnection]);
|
||||
@@ -313,7 +316,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeAvailability("unavailable");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 后端不可用");
|
||||
}, [backendConnectionError]);
|
||||
|
||||
@@ -371,10 +374,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
const personaState = useMemo(
|
||||
() => deriveAgentPersonaState(messages, {
|
||||
chatStatus: chat.status,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
connectionStatus
|
||||
}),
|
||||
[chat.status, messages, runtimeAvailability, statusLabel]
|
||||
[chat.status, connectionStatus, messages]
|
||||
);
|
||||
|
||||
const refreshSessionHistory = useCallback(async () => {
|
||||
@@ -478,7 +480,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRuntimeAvailability("failed");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 会话流恢复失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
@@ -589,7 +591,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
clearSessionId();
|
||||
setSessionTitle("新对话");
|
||||
setStatusLabel(nextStatusLabel);
|
||||
setRuntimeAvailability("connected");
|
||||
setConnectionStatus("online");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
setStreamRenderState({});
|
||||
@@ -793,6 +795,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
modelOptions,
|
||||
openMobilePanel,
|
||||
closeMobilePanel,
|
||||
connectionStatus,
|
||||
panelCollapsing,
|
||||
panelOpen,
|
||||
personaState,
|
||||
@@ -824,23 +827,17 @@ function deriveAgentPersonaState(
|
||||
messages: AgentChatMessage[],
|
||||
{
|
||||
chatStatus,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
connectionStatus
|
||||
}: {
|
||||
chatStatus: string;
|
||||
runtimeAvailability: AgentRuntimeAvailability;
|
||||
statusLabel: string;
|
||||
connectionStatus: AgentConnectionStatus;
|
||||
}
|
||||
): PersonaState {
|
||||
if (
|
||||
runtimeAvailability === "unavailable" ||
|
||||
runtimeAvailability === "failed" ||
|
||||
/失败|不可用|错误|降级/.test(statusLabel)
|
||||
) {
|
||||
if (connectionStatus === "offline") {
|
||||
return "asleep";
|
||||
}
|
||||
|
||||
if (hasPendingOperatorInput(messages)) {
|
||||
if (hasPendingAgentInput(messages)) {
|
||||
return "listening";
|
||||
}
|
||||
|
||||
@@ -848,7 +845,7 @@ function deriveAgentPersonaState(
|
||||
chatStatus === "submitted" ||
|
||||
chatStatus === "streaming" ||
|
||||
hasRunningSessionWork(messages) ||
|
||||
runtimeAvailability === "connecting"
|
||||
connectionStatus === "connecting"
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
@@ -856,14 +853,6 @@ function deriveAgentPersonaState(
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
||||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
||||
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
||||
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
||||
|
||||
@@ -7,15 +7,31 @@ export function useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth
|
||||
}: {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
conditionPanelExpanded?: boolean;
|
||||
agentPanelWidth?: number;
|
||||
}) {
|
||||
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
|
||||
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
|
||||
const valuesRef = useRef({
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
valuesRef.current = {
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
};
|
||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new WorkbenchMapController({
|
||||
@@ -24,14 +40,24 @@ export function useWorkbenchMapController({
|
||||
getPadding: () => {
|
||||
const map = mapRef.current;
|
||||
return map
|
||||
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
|
||||
? getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
valuesRef.current.leftPanelOpen,
|
||||
valuesRef.current.rightPanelOpen,
|
||||
valuesRef.current.conditionPanelExpanded,
|
||||
valuesRef.current.agentPanelWidth
|
||||
)
|
||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const controller = controllerRef.current;
|
||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
||||
const state = useSyncExternalStore(
|
||||
controller.subscribe,
|
||||
controller.getSnapshot,
|
||||
controller.getSnapshot
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
return { controller, state };
|
||||
}
|
||||
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
waterNetworkInteractionLayers
|
||||
} from "../map/layers";
|
||||
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||
import {
|
||||
VALUE_LABEL_LAYER_IDS,
|
||||
VALUE_LABEL_SOURCE_ID,
|
||||
createEmptyValueLabelCollection
|
||||
} from "../map/value-label";
|
||||
import { createValueLabelLayers } from "../map/value-label";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
@@ -47,8 +43,6 @@ declare global {
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
@@ -81,30 +75,25 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "mapbox-base",
|
||||
"mapbox-satellite": "mapbox-base",
|
||||
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])),
|
||||
...Object.fromEntries(
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
),
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
|
||||
}, [leftPanelOpen, rightPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
}, [impactVisible]);
|
||||
@@ -130,38 +119,31 @@ export function useWorkbenchMap({
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
const sources = createWaterNetworkSources();
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]));
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
||||
map.addSource(sourceId, sources[sourceId])
|
||||
);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
const activeScadaBusinessLayers = scadaRegistration.status === "unavailable"
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[0],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[1],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
|
||||
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
|
||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type === "symbol")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
@@ -169,7 +151,11 @@ export function useWorkbenchMap({
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-icons",
|
||||
scadaRegistration.status === "ready" ? "online" : scadaRegistration.status === "degraded" ? "degraded" : "offline",
|
||||
scadaRegistration.status === "ready"
|
||||
? "online"
|
||||
: scadaRegistration.status === "degraded"
|
||||
? "degraded"
|
||||
: "offline",
|
||||
scadaRegistration.status === "ready"
|
||||
? "SCADA 图标已就绪。"
|
||||
: scadaRegistration.status === "degraded"
|
||||
@@ -199,7 +185,12 @@ export function useWorkbenchMap({
|
||||
if (!message.includes("AbortError")) {
|
||||
const sourceGroupId = getSourceGroupFromErrorEvent(event);
|
||||
if (sourceGroupId) {
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
sourceGroupId,
|
||||
"offline",
|
||||
getSourceErrorMessage(sourceGroupId, message)
|
||||
);
|
||||
} else {
|
||||
setMapError(message);
|
||||
}
|
||||
@@ -248,7 +239,11 @@ export function useWorkbenchMap({
|
||||
function updateStatusFromSourceEvent(
|
||||
event: MapSourceDataEvent,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
|
||||
setSourceStatuses: (
|
||||
updater: (
|
||||
current: Record<string, WorkbenchSourceStatus>
|
||||
) => Record<string, WorkbenchSourceStatus>
|
||||
) => void
|
||||
) {
|
||||
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
if (!sourceGroupId) {
|
||||
@@ -259,11 +254,20 @@ function updateStatusFromSourceEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
sourceGroupId,
|
||||
status,
|
||||
getSourceStatusMessage(sourceGroupId, status)
|
||||
);
|
||||
}
|
||||
|
||||
function updateSourceStatus(
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
|
||||
setSourceStatuses: (
|
||||
updater: (
|
||||
current: Record<string, WorkbenchSourceStatus>
|
||||
) => Record<string, WorkbenchSourceStatus>
|
||||
) => void,
|
||||
sourceGroupId: string,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
message: string
|
||||
@@ -305,15 +309,21 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
|
||||
|
||||
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
return status === "online"
|
||||
? "底图瓦片已恢复正常。"
|
||||
: "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return status === "online" ? "供水管网矢量瓦片已加载。" : "供水管网瓦片请求中断,当前视图可能不完整。";
|
||||
return status === "online"
|
||||
? "供水管网矢量瓦片已加载。"
|
||||
: "供水管网瓦片请求中断,当前视图可能不完整。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
return status === "online"
|
||||
? "业务标注和影响范围已加载。"
|
||||
: "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
}
|
||||
|
||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
|
||||
@@ -6,6 +6,11 @@ export type {
|
||||
WorkbenchMapControllerState,
|
||||
WorkbenchMapErrorCode
|
||||
} from "./map/workbench-map-controller";
|
||||
export type {
|
||||
PipeFlowCalculation,
|
||||
PipeFlowDirection,
|
||||
PipeFlowSummary
|
||||
} from "./map/flow-overlay";
|
||||
export type {
|
||||
LayerGroupId,
|
||||
LayerGroupStylePatch,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelDefaultWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchCameraPadding,
|
||||
getWorkbenchViewportLayout
|
||||
} from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
@@ -11,3 +18,68 @@ describe("workbench basemap surface tone", () => {
|
||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workbench floating panels", () => {
|
||||
it("uses bounded desktop and wide panel widths", () => {
|
||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48
|
||||
});
|
||||
expect(getWorkbenchViewportLayout(1536)).toMatchObject({
|
||||
agentWidth: 540,
|
||||
conditionExpandedWidth: 960
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks the expanded condition panel in camera padding", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
conditionExpanded: false
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 524 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
conditionExpanded: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
||||
});
|
||||
|
||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 620,
|
||||
conditionOpen: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 900,
|
||||
conditionOpen: false
|
||||
}).left
|
||||
).toBe(744);
|
||||
});
|
||||
|
||||
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
|
||||
expect(getAgentPanelDefaultWidth(390)).toBe(500);
|
||||
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
|
||||
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
|
||||
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
|
||||
expect(getAgentPanelMaxWidth(1024)).toBe(484);
|
||||
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
|
||||
expect(getAgentPanelMaxWidth(1280)).toBe(516);
|
||||
expect(getAgentPanelMaxWidth(1440)).toBe(676);
|
||||
expect(getAgentPanelMaxWidth(1536)).toBe(720);
|
||||
expect(getAgentPanelMaxWidth(1920)).toBe(720);
|
||||
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,32 +4,80 @@ export const WORKBENCH_LAYOUT = {
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
maxAgentViewportRatio: 0.5,
|
||||
maxAgentWidth: 720,
|
||||
agentPanelLeftInset: 12,
|
||||
conditionPanelRightInset: 64,
|
||||
minimumAgentMapCorridor: 256,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
agentWidth: 540,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 960,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
},
|
||||
mobileSheet: {
|
||||
halfViewportRatio: 0.52,
|
||||
fullTopGap: 68
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function clampAgentPanelWidth(width: number, viewportWidth: number) {
|
||||
const minWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const maxWidth = viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
export function clampAgentPanelWidth(
|
||||
width: number,
|
||||
viewportWidth: number,
|
||||
conditionExpanded = false
|
||||
) {
|
||||
const minWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
|
||||
return Math.round(Math.min(Math.max(width, minWidth), maxWidth));
|
||||
return Math.round(
|
||||
Math.min(Math.max(width, minWidth), getAgentPanelMaxWidth(viewportWidth, conditionExpanded))
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentPanelDefaultWidth(viewportWidth: number, conditionExpanded = false) {
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||
return layout.agentWidth;
|
||||
}
|
||||
|
||||
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
|
||||
const collisionSafeWidth =
|
||||
viewportWidth -
|
||||
WORKBENCH_LAYOUT.agentPanelLeftInset -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
conditionWidth -
|
||||
WORKBENCH_LAYOUT.mapEdgeGap -
|
||||
layout.toolbarWidth;
|
||||
|
||||
return Math.min(layout.agentWidth, collisionSafeWidth);
|
||||
}
|
||||
|
||||
export function getAgentPanelMaxWidth(viewportWidth: number, conditionExpanded = false) {
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const conditionWidth = conditionExpanded ? layout.conditionExpandedWidth : layout.conditionWidth;
|
||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
const widthWithMapCorridor =
|
||||
viewportWidth -
|
||||
WORKBENCH_LAYOUT.agentPanelLeftInset -
|
||||
WORKBENCH_LAYOUT.conditionPanelRightInset -
|
||||
conditionWidth -
|
||||
WORKBENCH_LAYOUT.minimumAgentMapCorridor;
|
||||
|
||||
return Math.max(defaultWidth, Math.min(WORKBENCH_LAYOUT.maxAgentWidth, widthWithMapCorridor));
|
||||
}
|
||||
|
||||
export type WorkbenchPanelState = {
|
||||
agentOpen: boolean;
|
||||
agentWidth?: number;
|
||||
conditionOpen: boolean;
|
||||
conditionExpanded?: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
@@ -37,6 +85,7 @@ export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
export type WorkbenchViewportLayout = {
|
||||
agentWidth: number;
|
||||
conditionWidth: number;
|
||||
conditionExpandedWidth: number;
|
||||
toolbarWidth: number;
|
||||
tickerWidth: number;
|
||||
};
|
||||
@@ -65,8 +114,23 @@ export function getWorkbenchCameraPadding(viewportWidth: number, panels: Workben
|
||||
}
|
||||
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
||||
const agentWidth = panels.agentOpen
|
||||
? panels.agentWidth === undefined
|
||||
? getAgentPanelDefaultWidth(
|
||||
viewportWidth,
|
||||
panels.conditionOpen && Boolean(panels.conditionExpanded)
|
||||
)
|
||||
: clampAgentPanelWidth(
|
||||
panels.agentWidth,
|
||||
viewportWidth,
|
||||
panels.conditionOpen && Boolean(panels.conditionExpanded)
|
||||
)
|
||||
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen
|
||||
? panels.conditionExpanded
|
||||
? layout.conditionExpandedWidth
|
||||
: layout.conditionWidth
|
||||
: 0;
|
||||
|
||||
return {
|
||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||
@@ -82,6 +146,8 @@ export function getWorkbenchLayoutCssVariables() {
|
||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||
"--workbench-condition-expanded-width": `${WORKBENCH_LAYOUT.desktop.conditionExpandedWidth}px`,
|
||||
"--workbench-condition-expanded-width-wide": `${WORKBENCH_LAYOUT.wide.conditionExpandedWidth}px`,
|
||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
type CSSProperties
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
@@ -29,6 +31,10 @@ import {
|
||||
import { env } from "@/shared/config/env";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import {
|
||||
MobileWorkbenchSheet,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
@@ -92,10 +98,17 @@ export function MapWorkbenchPage() {
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||
);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||||
@@ -133,6 +146,9 @@ export function MapWorkbenchPage() {
|
||||
const handleChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
if (mediaQuery.matches) {
|
||||
setMobileSheet(null);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange();
|
||||
@@ -167,20 +183,75 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < 1440) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
agent.expandPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
agent.openMobilePanel();
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
|
||||
const closeMobileSheet = useCallback(() => {
|
||||
setMobileSheet(null);
|
||||
}, []);
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
||||
setAgentCollapsedForCondition(agent.panelOpen);
|
||||
agent.collapsePanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
setConditionFeedExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
setActiveToolId(null);
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileSheet === "condition") {
|
||||
closeMobileSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheet("condition");
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
const rightPanelExpanded =
|
||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature: handleSelectFeature,
|
||||
selectedFeature: detailFeature
|
||||
@@ -189,7 +260,9 @@ export function MapWorkbenchPage() {
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
|
||||
const activeAgentUiResults = useMemo(
|
||||
@@ -223,10 +296,9 @@ export function MapWorkbenchPage() {
|
||||
mode: activeMeasureModeId,
|
||||
unit: activeMeasureUnitId
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -489,6 +561,10 @@ export function MapWorkbenchPage() {
|
||||
|
||||
setConditionFeedVisible(true);
|
||||
setActiveToolId(null);
|
||||
if (!isLargeScreen) {
|
||||
setMobileSheet("condition");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
@@ -608,7 +684,13 @@ export function MapWorkbenchPage() {
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
),
|
||||
duration: trustedAction.durationMs ?? 500
|
||||
});
|
||||
showMapNotice({
|
||||
@@ -732,6 +814,7 @@ export function MapWorkbenchPage() {
|
||||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||||
activeSessionId: agent.sessionId,
|
||||
statusLabel: agent.statusLabel,
|
||||
connectionStatus: agent.connectionStatus,
|
||||
streaming: agent.streaming,
|
||||
messages: agent.messages,
|
||||
streamRenderState: agent.streamRenderState,
|
||||
@@ -757,7 +840,14 @@ export function MapWorkbenchPage() {
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
data-basemap-tone={basemapTone}
|
||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
||||
style={
|
||||
{
|
||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||
"--workbench-agent-current-width": `${
|
||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
@@ -773,17 +863,14 @@ export function MapWorkbenchPage() {
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
conditionFeedVisible={shouldShowConditionFeed}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={() => {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
setActiveToolId(null);
|
||||
}}
|
||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
@@ -800,19 +887,17 @@ export function MapWorkbenchPage() {
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
mobileOpen={agent.mobileOpen}
|
||||
mobilePanelCollapsing={agent.mobilePanelCollapsing}
|
||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onCloseMobilePanel={agent.closeMobilePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onOpenMobilePanel={agent.openMobilePanel}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
/>
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -820,13 +905,14 @@ export function MapWorkbenchPage() {
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
@@ -851,8 +937,8 @@ export function MapWorkbenchPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null ? (
|
||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel
|
||||
{...toolbarPanelProps}
|
||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||
@@ -890,16 +976,70 @@ export function MapWorkbenchPage() {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={openAgentPanelForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||
</button>
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="w-full justify-center"
|
||||
className="h-11 justify-center"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
|
||||
>
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mobileSheet ? (
|
||||
<MobileWorkbenchSheet
|
||||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
||||
snap={mobileSheetSnap}
|
||||
onSnapChange={setMobileSheetSnap}
|
||||
onClose={closeMobileSheet}
|
||||
>
|
||||
{mobileSheet === "agent" ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
presentation="mobile-sheet"
|
||||
onCollapse={closeMobileSheet}
|
||||
/>
|
||||
) : (
|
||||
<ScheduledConditionFeed
|
||||
presentation="mobile-sheet"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onClose={closeMobileSheet}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
)}
|
||||
</MobileWorkbenchSheet>
|
||||
) : null}
|
||||
|
||||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||||
|
||||
{!mapReady && !mapError ? (
|
||||
|
||||
@@ -72,7 +72,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -89,7 +89,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -124,7 +124,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
@@ -19,12 +15,40 @@ function createMap(width = 1280, height = 720) {
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 96 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 524 }
|
||||
},
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 96 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 524 }
|
||||
}
|
||||
])(
|
||||
"covers agent=$agentOpen and condition=$conditionOpen",
|
||||
({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses a committed Agent width for camera-safe operations", () => {
|
||||
expect(getWorkbenchPadding(true, false, false, 620)).toEqual({
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 32,
|
||||
left: 540
|
||||
});
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
@@ -59,7 +83,7 @@ describe("fitNetworkBounds", () => {
|
||||
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
|
||||
|
||||
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.8105, 6);
|
||||
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
|
||||
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
|
||||
expect(options).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
@@ -14,24 +12,35 @@ export type NetworkViewParams = {
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
export function getWorkbenchPadding(
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
map: MapLibreMap,
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
rightPanelOpen: boolean,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -41,14 +50,11 @@ export function fitNetworkBounds(
|
||||
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
|
||||
padding: PaddingOptions = GLOBAL_VIEW_PADDING
|
||||
) {
|
||||
map.fitBounds(
|
||||
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
|
||||
{
|
||||
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
@@ -84,10 +90,7 @@ function getMapViewportSize(map: MapLibreMap) {
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
return [
|
||||
webMercatorToLngLat(minX, minY),
|
||||
webMercatorToLngLat(maxX, maxY)
|
||||
];
|
||||
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
|
||||
}
|
||||
|
||||
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./map-colors";
|
||||
import { createBaseStyle } from "./sources";
|
||||
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
|
||||
const mapBackgrounds = [
|
||||
MAP_STYLE_TOKENS.canvas.primary,
|
||||
MAP_STYLE_TOKENS.canvas.secondary,
|
||||
@@ -47,7 +47,6 @@ describe("map color contrast", () => {
|
||||
});
|
||||
|
||||
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||
sourceColors.forEach(() => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
@@ -56,9 +55,27 @@ describe("map color contrast", () => {
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps operational states above 3:1 on every map background", () => {
|
||||
const operationalColors = [
|
||||
MAP_STYLE_TOKENS.state.success,
|
||||
MAP_STYLE_TOKENS.state.risk,
|
||||
MAP_STYLE_TOKENS.state.incident,
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
MAP_STYLE_TOKENS.state.agent
|
||||
];
|
||||
|
||||
operationalColors.forEach((stateColor) => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
expect(
|
||||
getContrastRatio(stateColor, background),
|
||||
createCoolorsContrastUrl(stateColor, background)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function summarizePipeFlowCalculations(
|
||||
calculations: Iterable<NormalizedPipeFlowCalculation>,
|
||||
invalid: number
|
||||
): PipeFlowSummary {
|
||||
const summary: PipeFlowSummary = {
|
||||
total: 0,
|
||||
forward: 0,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid
|
||||
};
|
||||
for (const calculation of calculations) {
|
||||
summary.total += 1;
|
||||
if (calculation.direction > 0) summary.forward += 1;
|
||||
else if (calculation.direction < 0) summary.reverse += 1;
|
||||
else summary.stopped += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function setPipeFlowState(
|
||||
map: MapLibreMap,
|
||||
calculation: NormalizedPipeFlowCalculation
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id: calculation.pipeId
|
||||
},
|
||||
{
|
||||
[FLOW_STATE_DIRECTION]: calculation.direction
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function applyFlowStateChanges(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
changes: readonly FlowStateChange[],
|
||||
revision: number
|
||||
) {
|
||||
const batchSize = 2_000;
|
||||
let offset = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const applyBatch = () => {
|
||||
if (revision !== state.revision) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const end = Math.min(offset + batchSize, changes.length);
|
||||
for (; offset < end; offset += 1) {
|
||||
const change = changes[offset];
|
||||
if (change.type === "remove") {
|
||||
removePipeFlowState(map, change.key);
|
||||
state.calculations.delete(change.key);
|
||||
} else {
|
||||
setPipeFlowState(map, change.calculation);
|
||||
state.calculations.set(change.key, change.calculation);
|
||||
}
|
||||
}
|
||||
if (offset >= changes.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(applyBatch);
|
||||
};
|
||||
applyBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function removePipeFlowState(map: MapLibreMap, id: string | number) {
|
||||
const target = {
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id
|
||||
};
|
||||
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
|
||||
}
|
||||
|
||||
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
|
||||
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_FORWARD_IMAGE_ID,
|
||||
createFlowPatternImage(1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_REVERSE_IMAGE_ID,
|
||||
createFlowPatternImage(-1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
|
||||
stopFlowAnimation(state);
|
||||
if (state.overlay) {
|
||||
map.removeControl(state.overlay as never);
|
||||
state.overlay = null;
|
||||
function createFlowPatternImage(
|
||||
direction: -1 | 1,
|
||||
reducedMotion: boolean
|
||||
): StyleImageInterface {
|
||||
let map: MapLibreMap | null = null;
|
||||
let lastOffset = Number.NaN;
|
||||
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
|
||||
|
||||
const draw = (offset: number) => {
|
||||
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
|
||||
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
|
||||
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
|
||||
const wrapped = (
|
||||
x - offset + FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
const pulse = wrapped < 18
|
||||
? Math.sin((Math.PI * wrapped) / 18)
|
||||
: 0;
|
||||
const alpha = Math.round(255 * pulse * edgeFade);
|
||||
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
|
||||
data[index] = 94;
|
||||
data[index + 1] = 234;
|
||||
data[index + 2] = 212;
|
||||
data[index + 3] = alpha;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
|
||||
|
||||
return {
|
||||
width: FLOW_PATTERN_WIDTH,
|
||||
height: FLOW_PATTERN_HEIGHT,
|
||||
data,
|
||||
onAdd(nextMap) {
|
||||
map = nextMap;
|
||||
},
|
||||
render() {
|
||||
if (reducedMotion) return false;
|
||||
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
|
||||
/ FLOW_PATTERN_DURATION_MS;
|
||||
const offset = Math.floor(
|
||||
progress * FLOW_PATTERN_WIDTH * direction
|
||||
+ FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
if (offset === lastOffset) {
|
||||
map?.triggerRepaint();
|
||||
return false;
|
||||
}
|
||||
lastOffset = offset;
|
||||
draw(offset);
|
||||
map?.triggerRepaint();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function stopFlowAnimation(state: FlowOverlayState) {
|
||||
if (state.animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(state.animationFrameId);
|
||||
state.animationFrameId = null;
|
||||
const mockDirectionExpression: ExpressionSpecification = [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
["%", ["to-number", ["id"], 0], 2],
|
||||
0
|
||||
],
|
||||
1,
|
||||
-1
|
||||
];
|
||||
|
||||
const directionExpression = (
|
||||
direction: -1 | 1,
|
||||
visibleOpacity: number,
|
||||
mockVisible = false
|
||||
): ExpressionSpecification => [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
[
|
||||
"coalesce",
|
||||
["feature-state", FLOW_STATE_DIRECTION],
|
||||
mockVisible ? mockDirectionExpression : 0
|
||||
],
|
||||
direction
|
||||
],
|
||||
visibleOpacity,
|
||||
0
|
||||
];
|
||||
|
||||
function ensureFlowLayers(map: MapLibreMap) {
|
||||
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_FORWARD_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
function isFlowFeature(feature: Feature): feature is FlowFeature {
|
||||
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
|
||||
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_REVERSE_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_REVERSE_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(-1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function flowLineLayout() {
|
||||
return {
|
||||
visibility: "visible" as const,
|
||||
"line-cap": "round" as const,
|
||||
"line-join": "round" as const
|
||||
};
|
||||
}
|
||||
|
||||
function setFlowLayerVisibility(
|
||||
map: MapLibreMap,
|
||||
visibility: "visible" | "none"
|
||||
) {
|
||||
[
|
||||
FLOW_LINE_LAYER_ID,
|
||||
FLOW_REVERSE_LINE_LAYER_ID
|
||||
].forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setFlowMockVisibility(
|
||||
map: MapLibreMap,
|
||||
visible: boolean
|
||||
) {
|
||||
const opacityByLayer = [
|
||||
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
|
||||
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
|
||||
] as const;
|
||||
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(
|
||||
layerId,
|
||||
property,
|
||||
directionExpression(direction, opacity, visible)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LayerExtension, type Layer } from "@deck.gl/core";
|
||||
|
||||
const flowPathUniforms = {
|
||||
name: "flowPath",
|
||||
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
|
||||
uniformTypes: { phase: "f32" }
|
||||
} as const;
|
||||
|
||||
export class FlowPathExtension extends LayerExtension {
|
||||
static extensionName = "FlowPathExtension";
|
||||
static defaultProps = { flowPhase: { type: "number", value: 0 } };
|
||||
|
||||
getShaders() {
|
||||
return {
|
||||
modules: [flowPathUniforms],
|
||||
inject: {
|
||||
"fs:DECKGL_FILTER_COLOR": `
|
||||
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
|
||||
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
|
||||
color.a *= 0.22 + flowPulse * 0.78;
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
draw(this: Layer) {
|
||||
const state = this.state as unknown as {
|
||||
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
|
||||
};
|
||||
state.model?.shaderInputs?.setProps({
|
||||
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
|
||||
opacity?: number;
|
||||
radiusMultiplier?: number;
|
||||
strokeMultiplier?: number;
|
||||
iconMultiplier?: number;
|
||||
};
|
||||
|
||||
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
|
||||
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
const values = patch as Record<string, unknown>;
|
||||
const allowed = geometry === "line"
|
||||
? ["color", "opacity", "widthMultiplier", "dash"]
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
|
||||
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
|
||||
if ("color" in values && !isColor(values.color)) return false;
|
||||
if ("fillColor" in values && !isColor(values.fillColor)) return false;
|
||||
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
|
||||
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
|
||||
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
|
||||
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
|
||||
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
if (patch.strokeMultiplier !== undefined) {
|
||||
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
|
||||
}
|
||||
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
|
||||
setLayout(
|
||||
map,
|
||||
`${groupId}-symbol`,
|
||||
"icon-size",
|
||||
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
|
||||
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
|
||||
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
|
||||
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
|
||||
if ("iconSize" in visual) {
|
||||
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
function scale(value: unknown, multiplier: number): unknown {
|
||||
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
|
||||
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
||||
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
|
||||
map.setLayoutProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
}
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -185,8 +195,8 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 0.86,
|
||||
"raster-saturation": -0.16,
|
||||
"raster-contrast": 0.02
|
||||
"raster-saturation": -0.3,
|
||||
"raster-contrast": 0.1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,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]]], {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export type WorkbenchUser = {
|
||||
|
||||
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
||||
|
||||
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply";
|
||||
|
||||
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
|
||||
|
||||
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
||||
|
||||
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
||||
@@ -71,12 +75,12 @@ export type ScheduledConditionReport = {
|
||||
}[];
|
||||
};
|
||||
|
||||
type ScheduledConditionBase = {
|
||||
type ScheduledConditionBase<TStatus extends ScheduledConditionItemStatus> = {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: ScheduledConditionStatus;
|
||||
status: TStatus;
|
||||
riskLevel: ScheduledConditionRiskLevel;
|
||||
updatedAt: number;
|
||||
detail?: string;
|
||||
@@ -84,7 +88,7 @@ type ScheduledConditionBase = {
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledConditionStatus> & {
|
||||
kind: "condition";
|
||||
taskId: ScheduledConditionTaskId;
|
||||
sessionId: string;
|
||||
@@ -95,7 +99,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
report?: ScheduledConditionReport;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
||||
kind: "work_order";
|
||||
code: string;
|
||||
source: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getRunningWorkflowDefinition } from "../data/running-workflows";
|
||||
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
|
||||
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
awaiting_reply: "待复令",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
})
|
||||
})
|
||||
+80
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
+628
-176
File diff suppressed because it is too large
Load Diff
@@ -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,287 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => {
|
||||
type BoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
async function waitForAnimations(locator: Locator) {
|
||||
await locator.evaluate(async (element) => {
|
||||
const animations = element
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
|
||||
await Promise.all(
|
||||
animations.map((animation) => animation.finished.catch(() => undefined))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const topBar = page.locator("header.acrylic-navigation");
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
||||
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
||||
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentHeader).toBeVisible();
|
||||
await expect(agentComposer).toBeVisible();
|
||||
await waitForAnimations(agentPanel);
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expectAcrylicSurface(agentPanel);
|
||||
await expectTransparentAcrylicContext(agentHeader);
|
||||
await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation"));
|
||||
await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band"));
|
||||
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
|
||||
await expectIntegratedHeader(agentHeader);
|
||||
await expectContextualAcrylicSurface(agentComposer, {
|
||||
backdropFilter: "blur(",
|
||||
maximumAlpha: 0.9,
|
||||
minimumAlpha: 0.84
|
||||
});
|
||||
await expectLightweightMaterialContext(agentConversation);
|
||||
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
|
||||
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
|
||||
await expect(agentComposer).toHaveCSS("border-radius", "16px");
|
||||
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
|
||||
await expect(
|
||||
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
).toHaveCSS("color", "rgb(51, 65, 85)");
|
||||
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
|
||||
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
|
||||
|
||||
const panelBox = await agentPanel.boundingBox();
|
||||
const conversationBox = await agentConversation.boundingBox();
|
||||
const scrollBox = await agentConversationScroll.boundingBox();
|
||||
const composerBox = await agentComposer.boundingBox();
|
||||
expect(panelBox).not.toBeNull();
|
||||
expect(conversationBox).not.toBeNull();
|
||||
expect(scrollBox).not.toBeNull();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
|
||||
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
|
||||
expect(
|
||||
Math.abs(
|
||||
composerBox!.x -
|
||||
panelBox!.x -
|
||||
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
|
||||
)
|
||||
).toBeLessThanOrEqual(1);
|
||||
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
||||
|
||||
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer);
|
||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
||||
await expect
|
||||
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
|
||||
.toEqual(beforeFocus);
|
||||
});
|
||||
|
||||
async function expectAcrylicSurface(shell: Locator) {
|
||||
const composition = await shell.evaluate((element) => {
|
||||
async function expectIntegratedHeader(header: Locator) {
|
||||
const composition = await header.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
borderRadius: style.borderRadius
|
||||
};
|
||||
});
|
||||
|
||||
expect(composition.backdropFilter).toBe("none");
|
||||
expect(getAlpha(composition.backgroundColor)).toBe(0);
|
||||
expect(composition.borderTopWidth).toBe("0px");
|
||||
expect(composition.borderRadius).toBe("0px");
|
||||
}
|
||||
|
||||
test("desktop Agent header expands into one attached acrylic history surface", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
||||
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
|
||||
const historyButton = agentPanel.getByRole("button", {
|
||||
name: "打开 Agent 历史记录"
|
||||
});
|
||||
const headerBefore = await agentHeader.boundingBox();
|
||||
const conversationBefore = await agentConversation.boundingBox();
|
||||
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
|
||||
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
|
||||
|
||||
await historyButton.click();
|
||||
|
||||
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history).toBeVisible();
|
||||
await waitForAnimations(history);
|
||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||
await page.mouse.move(720, 320);
|
||||
await expectAttachedHistoryExtension(history);
|
||||
await expectTransparentExpandedHeader(agentHeader, agentPanel);
|
||||
await expectUnifiedHistoryStructure(history);
|
||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
||||
await expect
|
||||
.poll(() => getSurfacePresentation(historyButton))
|
||||
.toEqual(historyButtonPresentationBefore);
|
||||
|
||||
const sessionTrigger = history.locator(".agent-history-session-trigger").first();
|
||||
const sessionTriggerBox = await sessionTrigger.boundingBox();
|
||||
const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox();
|
||||
expect(sessionTriggerBox).not.toBeNull();
|
||||
expect(historyHeadingBox).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
sessionTriggerBox!.x + sessionTriggerBox!.width / 2,
|
||||
sessionTriggerBox!.y + sessionTriggerBox!.height / 2
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(sessionTrigger).toHaveCSS("outline-style", "none");
|
||||
await page.mouse.move(
|
||||
historyHeadingBox!.x + historyHeadingBox!.width / 2,
|
||||
historyHeadingBox!.y + historyHeadingBox!.height / 2
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const headerBox = await agentHeader.boundingBox();
|
||||
const agentPanelBox = await agentPanel.boundingBox();
|
||||
const historyBox = await history.boundingBox();
|
||||
const conversationAfter = await agentConversation.boundingBox();
|
||||
expect(headerBefore).not.toBeNull();
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(agentPanelBox).not.toBeNull();
|
||||
expect(historyBox).not.toBeNull();
|
||||
expect(conversationBefore).not.toBeNull();
|
||||
expect(conversationAfter).not.toBeNull();
|
||||
expectRoundedValue(headerBox!.y, headerBefore!.y);
|
||||
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
|
||||
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
|
||||
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
|
||||
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
|
||||
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
|
||||
expectRoundedValue(historyBox!.x, headerBox!.x);
|
||||
expectRoundedValue(historyBox!.width, headerBox!.width);
|
||||
expectRoundedBox(conversationAfter!, conversationBefore!);
|
||||
|
||||
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
||||
const renameInput = history.locator("input").first();
|
||||
const editingItem = renameInput.locator(
|
||||
"xpath=ancestor::*[contains(@class, 'agent-history-item')][1]"
|
||||
);
|
||||
await expect(renameInput).toBeFocused();
|
||||
await expect(renameInput).toHaveCSS("box-shadow", "none");
|
||||
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
|
||||
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
|
||||
await history.getByRole("button", { name: "取消重命名" }).click();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(history).toBeHidden();
|
||||
await expect(historyButton).toBeFocused();
|
||||
await expect(historyButton).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
|
||||
|
||||
await historyButton.click();
|
||||
await expect(history).toBeVisible();
|
||||
await page.mouse.click(720, 320);
|
||||
await expect(history).toBeHidden();
|
||||
});
|
||||
|
||||
test("mobile Agent mirrors the desktop floating history surface", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
|
||||
const agentPanel = page
|
||||
.locator('aside[aria-label="Agent 命令面板"]')
|
||||
.filter({ visible: true });
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await waitForAnimations(agentPanel);
|
||||
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
const sheetHandle = mobileSheet.locator(".mobile-workbench-sheet-handle");
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
const agentConversation = agentPanel.getByRole("log");
|
||||
const historyButton = agentPanel.getByRole("button", {
|
||||
name: "打开 Agent 历史记录"
|
||||
});
|
||||
const headerBefore = await agentHeader.boundingBox();
|
||||
const conversationBefore = await agentConversation.boundingBox();
|
||||
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
|
||||
const mobileSheetFrameBefore = await expectAcrylicOutline(mobileSheet);
|
||||
|
||||
await historyButton.click();
|
||||
|
||||
const history = agentPanel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history).toBeVisible();
|
||||
await waitForAnimations(history);
|
||||
await page.mouse.move(10, 100);
|
||||
await expectAttachedHistoryExtension(history);
|
||||
await expectTransparentExpandedHeader(agentHeader, mobileSheet);
|
||||
await expectUnifiedHistoryStructure(history);
|
||||
await expect.poll(() => getAcrylicFrame(mobileSheet)).toEqual(mobileSheetFrameBefore);
|
||||
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||
await expect
|
||||
.poll(() => getSurfacePresentation(historyButton))
|
||||
.toEqual(historyButtonPresentationBefore);
|
||||
await expect(sheetHandle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
|
||||
const panelBox = await agentPanel.boundingBox();
|
||||
const headerAfter = await agentHeader.boundingBox();
|
||||
const historyBox = await history.boundingBox();
|
||||
const conversationAfter = await agentConversation.boundingBox();
|
||||
expect(panelBox).not.toBeNull();
|
||||
expect(headerBefore).not.toBeNull();
|
||||
expect(headerAfter).not.toBeNull();
|
||||
expect(historyBox).not.toBeNull();
|
||||
expect(conversationBefore).not.toBeNull();
|
||||
expect(conversationAfter).not.toBeNull();
|
||||
expectRoundedValue(headerAfter!.y, headerBefore!.y);
|
||||
expectRoundedValue(headerAfter!.x, panelBox!.x);
|
||||
expectRoundedValue(headerAfter!.y, panelBox!.y - 28);
|
||||
expectRoundedValue(headerAfter!.width, panelBox!.width);
|
||||
expect(headerAfter!.height).toBeGreaterThan(headerBefore!.height);
|
||||
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
|
||||
expectRoundedValue(historyBox!.width, headerAfter!.width);
|
||||
expect(headerAfter!.height).toBeLessThan(panelBox!.height);
|
||||
expectRoundedBox(conversationAfter!, conversationBefore!);
|
||||
await expect(agentConversation).toHaveClass(/agent-panel-conversation-canvas/);
|
||||
await expect(agentPanel.locator(".agent-panel-composer")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(history).toBeHidden();
|
||||
await expect(historyButton).toBeFocused();
|
||||
});
|
||||
|
||||
test("Agent floating acrylic respects forced color mode", async ({ page }) => {
|
||||
await page.emulateMedia({ forcedColors: "active" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const composer = page.locator(".agent-panel-composer");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(composer).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(composer).toHaveCSS("background-image", "none");
|
||||
await expect(composer).toHaveCSS("box-shadow", "none");
|
||||
});
|
||||
|
||||
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
|
||||
const composition = await shell.evaluate((element, allowedSelector) => {
|
||||
const style = getComputedStyle(element);
|
||||
const filteredDescendants: string[] = [];
|
||||
|
||||
for (const descendant of element.querySelectorAll("*")) {
|
||||
if (getComputedStyle(descendant).backdropFilter !== "none") {
|
||||
if (
|
||||
getComputedStyle(descendant).backdropFilter !== "none" &&
|
||||
!(allowedSelector && descendant.matches(allowedSelector))
|
||||
) {
|
||||
filteredDescendants.push(descendant.className);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +293,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 +302,199 @@ async function expectAcrylicSurface(shell: Locator) {
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
|
||||
async function expectContextualAcrylicSurface(
|
||||
context: Locator,
|
||||
{
|
||||
backdropFilter = "none",
|
||||
maximumAlpha = 0.7,
|
||||
minimumAlpha = 0.35
|
||||
}: {
|
||||
backdropFilter?: string;
|
||||
maximumAlpha?: number;
|
||||
minimumAlpha?: number;
|
||||
} = {}
|
||||
) {
|
||||
const composition = await context.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
boxShadow: style.boxShadow
|
||||
};
|
||||
});
|
||||
|
||||
if (backdropFilter === "none") {
|
||||
expect(composition.backdropFilter).toBe("none");
|
||||
} else {
|
||||
expect(composition.backdropFilter).toContain(backdropFilter);
|
||||
}
|
||||
expect(getAlpha(composition.backgroundColor)).toBeGreaterThan(minimumAlpha);
|
||||
expect(getAlpha(composition.backgroundColor)).toBeLessThan(maximumAlpha);
|
||||
expect(composition.backgroundImage).not.toBe("none");
|
||||
expect(composition.boxShadow).not.toBe("none");
|
||||
}
|
||||
|
||||
async function expectAttachedHistoryExtension(history: Locator) {
|
||||
const presentation = await history.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
borderLeftWidth: style.borderLeftWidth,
|
||||
borderRightWidth: style.borderRightWidth,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
borderTopLeftRadius: style.borderTopLeftRadius,
|
||||
borderTopRightRadius: style.borderTopRightRadius,
|
||||
boxShadow: style.boxShadow
|
||||
};
|
||||
});
|
||||
|
||||
expect(presentation.backdropFilter).toContain("blur(");
|
||||
expect(getAlpha(presentation.backgroundColor)).toBeGreaterThanOrEqual(0.78);
|
||||
expect(getAlpha(presentation.backgroundColor)).toBeLessThanOrEqual(0.9);
|
||||
expect(presentation.backgroundImage).not.toBe("none");
|
||||
expect(presentation.borderBottomWidth).toBe("0px");
|
||||
expect(presentation.borderLeftWidth).toBe("0px");
|
||||
expect(presentation.borderRightWidth).toBe("0px");
|
||||
expect(presentation.borderTopWidth).toBe("0px");
|
||||
expect(presentation.borderTopLeftRadius).toBe("0px");
|
||||
expect(presentation.borderTopRightRadius).toBe("0px");
|
||||
expect(presentation.borderBottomLeftRadius).toBe("16px");
|
||||
expect(presentation.borderBottomRightRadius).toBe("16px");
|
||||
expect(presentation.boxShadow).toBe("none");
|
||||
}
|
||||
|
||||
async function getAcrylicFrame(surface: Locator) {
|
||||
return surface.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
borderBottomColor: style.borderBottomColor,
|
||||
borderLeftColor: style.borderLeftColor,
|
||||
borderRightColor: style.borderRightColor,
|
||||
borderTopColor: style.borderTopColor,
|
||||
boxShadow: style.boxShadow
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAcrylicOutline(surface: Locator) {
|
||||
const frame = await getAcrylicFrame(surface);
|
||||
|
||||
expect(frame.borderTopColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(frame.borderRightColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(frame.borderBottomColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(frame.borderLeftColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(frame.boxShadow).toContain("0px 1px 0px 0px inset");
|
||||
expect(frame.boxShadow).toContain("0px 0px 0px 1px");
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
async function expectUnifiedHistoryStructure(history: Locator) {
|
||||
const structure = await history.evaluate((element) => {
|
||||
const paragraphs = Array.from(element.querySelectorAll("p"));
|
||||
const heading = paragraphs.find((paragraph) => paragraph.textContent?.trim() === "历史记录");
|
||||
const count = paragraphs.find((paragraph) =>
|
||||
/^(?:\d+ 个会话|暂无历史会话)$/.test(paragraph.textContent?.trim() ?? "")
|
||||
);
|
||||
|
||||
return {
|
||||
countTagName: count?.tagName ?? null,
|
||||
headingTagName: heading?.tagName ?? null,
|
||||
siblings: Boolean(heading && count && heading.parentElement === count.parentElement)
|
||||
};
|
||||
});
|
||||
|
||||
expect(structure).toEqual({
|
||||
countTagName: "P",
|
||||
headingTagName: "P",
|
||||
siblings: true
|
||||
});
|
||||
await expect(history.getByRole("button", { name: "重命名对话" }).first()).toBeVisible();
|
||||
await expect(history.getByRole("button", { name: "删除对话" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectTransparentExpandedHeader(header: Locator, agentSurface: Locator) {
|
||||
const [presentation, agentOutlineColor] = await Promise.all([
|
||||
header.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderBottomColor: style.borderBottomColor,
|
||||
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
borderLeftWidth: style.borderLeftWidth,
|
||||
borderRightWidth: style.borderRightWidth,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
boxShadow: style.boxShadow
|
||||
};
|
||||
}),
|
||||
agentSurface.evaluate((element) =>
|
||||
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
|
||||
)
|
||||
]);
|
||||
|
||||
expect(getAlpha(presentation.backgroundColor)).toBe(0);
|
||||
expect(presentation.borderBottomColor).toBe(agentOutlineColor);
|
||||
expect(presentation.borderBottomLeftRadius).toBe("16px");
|
||||
expect(presentation.borderBottomRightRadius).toBe("16px");
|
||||
expect(presentation.borderBottomWidth).toBe("1px");
|
||||
expect(presentation.borderLeftWidth).toBe("0px");
|
||||
expect(presentation.borderRightWidth).toBe("0px");
|
||||
expect(presentation.borderTopWidth).toBe("0px");
|
||||
expect(presentation.boxShadow).toContain("0px 12px 12px -12px");
|
||||
}
|
||||
|
||||
async function getSurfacePresentation(surface: Locator) {
|
||||
return surface.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderColor: style.borderColor,
|
||||
borderRadius: style.borderRadius,
|
||||
boxShadow: style.boxShadow,
|
||||
color: style.color
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getAlpha(color: string) {
|
||||
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
|
||||
|
||||
return match ? Number(match[1]) : 1;
|
||||
}
|
||||
|
||||
async function expectTransparentAcrylicContext(header: Locator) {
|
||||
const style = await header.evaluate((element) => {
|
||||
async function expectLightweightMaterialContext(context: Locator) {
|
||||
const style = await context.evaluate((element) => {
|
||||
const computedStyle = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: computedStyle.backdropFilter,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
className: element.className,
|
||||
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
|
||||
};
|
||||
});
|
||||
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(getAlpha(style.backgroundColor)).toBeGreaterThan(0);
|
||||
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.3);
|
||||
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
||||
expect(String(style.className)).not.toContain("acrylic-");
|
||||
}
|
||||
|
||||
async function expectLightweightContextSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(surfaceStyles.length).toBeGreaterThan(0);
|
||||
for (const style of surfaceStyles) {
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSolidInternalSurfaces(surfaces: Locator) {
|
||||
async function expectUnfilteredInternalSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
@@ -109,3 +514,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,6 +1,6 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
|
||||
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
@@ -16,13 +16,15 @@ test("Agent panel resizes by drag without exceeding half the viewport", async ({
|
||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
|
||||
await page.keyboard.press("End");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
|
||||
});
|
||||
|
||||
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
|
||||
@@ -57,21 +59,31 @@ test.describe("mobile touch layout", () => {
|
||||
const buttonBox = await openButton.boundingBox();
|
||||
expect(buttonBox).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2);
|
||||
await page.touchscreen.tap(
|
||||
buttonBox!.x + buttonBox!.width / 2,
|
||||
buttonBox!.y + buttonBox!.height / 2
|
||||
);
|
||||
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
// Temporarily disabled: this regression flow would start a real Agent run.
|
||||
// Agent analysis must be explicitly triggered by a user, not by automated tests.
|
||||
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
||||
await page.getByRole("button", { name: "工况汇总" }).click();
|
||||
// Agent execution must remain an explicit user action.
|
||||
// await page.getByRole("button", { name: "工况汇总" }).click();
|
||||
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test("submits a custom answer with the actionable id after a later tool placeholder", async ({
|
||||
page
|
||||
}) => {
|
||||
const replies: Array<{ pathname: string; body: unknown }> = [];
|
||||
await mockQuestionApi(page, replies);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
|
||||
await prompt.fill("测试 question 自定义回答");
|
||||
await page
|
||||
.getByRole("button", { name: "发送 Agent 指令" })
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
await panel.getByRole("radio", { name: "自定义回答" }).click();
|
||||
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
|
||||
await panel.getByRole("button", { name: "提交回答" }).click();
|
||||
|
||||
await expect.poll(() => replies).toEqual([
|
||||
{
|
||||
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
||||
body: {
|
||||
session_id: "question-session",
|
||||
answers: [["分析高峰时段压力"]]
|
||||
}
|
||||
}
|
||||
]);
|
||||
await expect(panel.getByText("已回答", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "提交回答" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps the custom answer control within a 375px Agent sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await mockQuestionApi(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
const prompt = panel.getByPlaceholder(/输入调度问题/);
|
||||
await prompt.fill("测试窄屏 question");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await panel.getByRole("radio", { name: "自定义回答" }).click();
|
||||
|
||||
const textarea = panel.getByPlaceholder("输入自定义回答");
|
||||
await expect(textarea).toBeVisible();
|
||||
const box = await textarea.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
|
||||
});
|
||||
|
||||
async function mockQuestionApi(
|
||||
page: Page,
|
||||
replies: Array<{ pathname: string; body: unknown }>
|
||||
) {
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname.endsWith("/stream")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
},
|
||||
body: createQuestionStream()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/question/question-1/reply")) {
|
||||
replies.push({
|
||||
pathname,
|
||||
body: request.postDataJSON()
|
||||
});
|
||||
await route.fulfill({ json: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/models")) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
default_model: "test/model",
|
||||
models: [
|
||||
{
|
||||
id: "test/model",
|
||||
label: "测试模型",
|
||||
description: "Question 交互测试",
|
||||
icon: "bolt"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/sessions")) {
|
||||
await route.fulfill({ json: { sessions: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ json: {} });
|
||||
});
|
||||
}
|
||||
|
||||
function createQuestionStream() {
|
||||
const question = {
|
||||
session_id: "question-session",
|
||||
questions: [
|
||||
{
|
||||
header: "分析范围",
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{
|
||||
label: "雨污混接分析",
|
||||
description: "检查混接问题"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
tool: {
|
||||
messageID: "assistant-question",
|
||||
callID: "call-1"
|
||||
}
|
||||
};
|
||||
const parts = [
|
||||
{ type: "start", messageId: "assistant-question" },
|
||||
{ type: "text-start", id: "answer" },
|
||||
{ type: "text-delta", id: "answer", delta: "请选择分析范围。" },
|
||||
{
|
||||
type: "data-question_request",
|
||||
id: "question-1",
|
||||
data: {
|
||||
...question,
|
||||
request_id: "question-1",
|
||||
created_at: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "data-question_request",
|
||||
id: "call-1",
|
||||
data: {
|
||||
...question,
|
||||
request_id: "call-1",
|
||||
created_at: 120
|
||||
}
|
||||
},
|
||||
{ type: "text-end", id: "answer" },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
];
|
||||
|
||||
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
|
||||
}
|
||||
@@ -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[] = [];
|
||||
@@ -83,3 +83,48 @@ test("speech recognition writes final transcript into the prompt", async ({ page
|
||||
|
||||
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位");
|
||||
});
|
||||
|
||||
test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {
|
||||
start() {}
|
||||
|
||||
stop() {}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
const pulse = page.locator('[data-slot="voice-input-pulse"]');
|
||||
await expect(pulse).toBeVisible();
|
||||
const seamOpacity = await pulse.evaluate((element) => {
|
||||
const animation = element
|
||||
.getAnimations()
|
||||
.find((candidate) =>
|
||||
(candidate.effect as KeyframeEffect | null)
|
||||
?.getKeyframes()
|
||||
.some((keyframe) => keyframe.opacity !== undefined)
|
||||
);
|
||||
if (!animation?.effect) {
|
||||
throw new Error("Voice input pulse opacity animation was not found");
|
||||
}
|
||||
|
||||
animation.pause();
|
||||
const duration = Number(animation.effect.getTiming().duration);
|
||||
animation.currentTime = duration - 1;
|
||||
const before = Number(window.getComputedStyle(element).opacity);
|
||||
animation.currentTime = duration + 1;
|
||||
const after = Number(window.getComputedStyle(element).opacity);
|
||||
|
||||
return { before, after };
|
||||
});
|
||||
|
||||
expect(seamOpacity.before).toBeLessThan(0.02);
|
||||
expect(seamOpacity.after).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("mobile workbench sheet", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("keeps the bottom launcher controls at one height", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const agentButton = page.getByRole("button", { name: "打开 Agent 面板" });
|
||||
const mapToolbar = page.locator('nav[aria-label="地图工具"]:visible');
|
||||
const conditionButton = page.getByRole("button", { name: "打开工况任务" });
|
||||
const boxes = await Promise.all([
|
||||
agentButton.boundingBox(),
|
||||
mapToolbar.boundingBox(),
|
||||
conditionButton.boundingBox()
|
||||
]);
|
||||
|
||||
boxes.forEach((box) => expect(box).not.toBeNull());
|
||||
expect(boxes.map((box) => Math.round(box!.height))).toEqual([44, 44, 44]);
|
||||
expect(boxes.map((box) => Math.round(box!.y))).toEqual([
|
||||
Math.round(boxes[0]!.y),
|
||||
Math.round(boxes[0]!.y),
|
||||
Math.round(boxes[0]!.y)
|
||||
]);
|
||||
});
|
||||
|
||||
test("supports half and full heights, keyboard control, and Escape close", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
const handle = page.getByRole("button", { name: /Agent 工作台抽屉高度/ });
|
||||
const closeButton = page.getByRole("button", { name: "关闭 Agent 面板" });
|
||||
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(closeButton).toHaveCount(1);
|
||||
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
|
||||
await expect(closeButton).toHaveCSS("border-radius", "12px");
|
||||
await expect.poll(async () => Math.round((await closeButton.boundingBox())?.height ?? 0)).toBe(
|
||||
40
|
||||
);
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
sheet.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const nestedFilters = Array.from(element.querySelectorAll("*")).filter(
|
||||
(descendant) => getComputedStyle(descendant).backdropFilter !== "none"
|
||||
);
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
nestedFilterCount: nestedFilters.length,
|
||||
opacity: style.opacity,
|
||||
transform: style.transform
|
||||
};
|
||||
})
|
||||
)
|
||||
.toEqual({
|
||||
backdropFilter: "blur(18px) saturate(1.08)",
|
||||
nestedFilterCount: 1,
|
||||
opacity: "1",
|
||||
transform: "none"
|
||||
});
|
||||
|
||||
await handle.click();
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(776);
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
|
||||
|
||||
await handle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
||||
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await page.keyboard.press("End");
|
||||
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 700 });
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(632);
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.y ?? 0)).toBe(68);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toBeHidden();
|
||||
});
|
||||
|
||||
test("keeps Agent and condition sheets mutually exclusive", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
const agentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
await expect(agentSheet).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await agentSheet.boundingBox())?.height ?? 0)).toBe(
|
||||
439
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
const conditionSheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
await expect(conditionSheet).toBeVisible();
|
||||
await expect.poll(async () =>
|
||||
Math.round((await conditionSheet.boundingBox())?.height ?? 0)
|
||||
).toBe(439);
|
||||
await expect(
|
||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("offers a touch close action for the condition sheet", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
const closeButton = page.getByRole("button", { name: "关闭工况任务" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(closeButton).toHaveClass(/agent-panel-icon-button/);
|
||||
await expect(closeButton).toHaveCSS("border-radius", "12px");
|
||||
await closeButton.click();
|
||||
expect(await sheet.count()).toBe(0);
|
||||
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
|
||||
await sheet.getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
|
||||
await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible();
|
||||
const workflowTitle = sheet.getByText("SCADA 诊断流程", { exact: true });
|
||||
await expect(workflowTitle).toBeVisible();
|
||||
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
const awaitingReplyWorkOrder = sheet
|
||||
.locator('button[aria-pressed]')
|
||||
.filter({ hasText: "系统工单:泵站边界复核复令" });
|
||||
|
||||
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
|
||||
await awaitingReplyWorkOrder.click();
|
||||
await expect(sheet.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toHaveCount(0);
|
||||
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,254 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
const timeline = panel.getByTestId("scheduled-condition-timeline");
|
||||
const detailViewport = panel.locator(".scheduled-feed-detail-viewport");
|
||||
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
|
||||
const collapsedPanelBox = await panel.boundingBox();
|
||||
const collapsedTimelineBox = await timeline.boundingBox();
|
||||
expect(collapsedPanelBox).not.toBeNull();
|
||||
expect(collapsedTimelineBox).not.toBeNull();
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
const expandedPanelBox = await panel.boundingBox();
|
||||
const expandedTimelineBox = await timeline.boundingBox();
|
||||
const expandedDetailViewportBox = await detailViewport.boundingBox();
|
||||
const expandedDetailScrollBox = await detailScroll.boundingBox();
|
||||
expect(expandedPanelBox).not.toBeNull();
|
||||
expect(expandedTimelineBox).not.toBeNull();
|
||||
expect(expandedDetailViewportBox).not.toBeNull();
|
||||
expect(expandedDetailScrollBox).not.toBeNull();
|
||||
expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe(
|
||||
Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width)
|
||||
);
|
||||
expect(Math.round(expandedTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.width)
|
||||
);
|
||||
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
|
||||
);
|
||||
expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual(
|
||||
expandedPanelBox!.y + expandedPanelBox!.height - 12
|
||||
);
|
||||
expect(Math.round(expandedDetailViewportBox!.y + expandedDetailViewportBox!.height)).toBe(
|
||||
Math.round(expandedTimelineBox!.y + expandedTimelineBox!.height)
|
||||
);
|
||||
expect(expandedDetailScrollBox!.y).toBeGreaterThanOrEqual(
|
||||
expandedDetailViewportBox!.y + 8
|
||||
);
|
||||
expect(expandedDetailScrollBox!.y + expandedDetailScrollBox!.height).toBeLessThanOrEqual(
|
||||
expandedDetailViewportBox!.y + expandedDetailViewportBox!.height - 8
|
||||
);
|
||||
await expect(detailViewport).toHaveCSS("border-radius", "12px");
|
||||
const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1");
|
||||
expect(await readScrollbarPresentation(detailScroll)).toEqual(
|
||||
await readScrollbarPresentation(timelineScroll)
|
||||
);
|
||||
const futureScrollBox = await timeline.locator(".scheduled-feed-scroll").first().boundingBox();
|
||||
const recentScrollBox = await timelineScroll.boundingBox();
|
||||
expect(futureScrollBox).not.toBeNull();
|
||||
expect(recentScrollBox).not.toBeNull();
|
||||
expect(Math.round(futureScrollBox!.x + futureScrollBox!.width)).toBe(
|
||||
Math.round(recentScrollBox!.x + recentScrollBox!.width)
|
||||
);
|
||||
|
||||
await panel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
const restoredTimelineBox = await timeline.boundingBox();
|
||||
expect(restoredTimelineBox).not.toBeNull();
|
||||
expect(Math.round(restoredTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.width)
|
||||
);
|
||||
});
|
||||
|
||||
test("narrow desktop keeps Agent and expanded conditions from overlapping", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(agentPanel).toHaveCSS("width", "484px");
|
||||
|
||||
const compactAgentBox = await agentPanel.boundingBox();
|
||||
const compactConditionBox = await panel.boundingBox();
|
||||
expect(compactAgentBox).not.toBeNull();
|
||||
expect(compactConditionBox).not.toBeNull();
|
||||
expect(compactConditionBox!.x - (compactAgentBox!.x + compactAgentBox!.width)).toBeGreaterThanOrEqual(
|
||||
24
|
||||
);
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
|
||||
await expect(panel).toHaveCSS("width", "852px");
|
||||
|
||||
const expandedConditionBox = await panel.boundingBox();
|
||||
const collapsedAgentBox = await page
|
||||
.locator('aside[aria-label="Agent 折叠栏"]')
|
||||
.boundingBox();
|
||||
expect(expandedConditionBox).not.toBeNull();
|
||||
expect(collapsedAgentBox).not.toBeNull();
|
||||
expect(
|
||||
expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width)
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
|
||||
await panel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
});
|
||||
|
||||
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
|
||||
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
|
||||
const baselineHeight = await detailScroll.evaluate(
|
||||
(element) => element.getBoundingClientRect().height
|
||||
);
|
||||
const targetCondition = panel
|
||||
.getByTestId("scheduled-condition-timeline")
|
||||
.getByRole("button", { name: /SCADA 数据诊断/ })
|
||||
.nth(1);
|
||||
const sampledHeights = await targetCondition.evaluate(async (element) => {
|
||||
element.click();
|
||||
const heights: number[] = [];
|
||||
const startedAt = performance.now();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const sample = () => {
|
||||
const detail = document.querySelector<HTMLElement>(
|
||||
'[data-testid="scheduled-condition-detail-scroll"]'
|
||||
);
|
||||
if (detail) {
|
||||
heights.push(detail.getBoundingClientRect().height);
|
||||
}
|
||||
|
||||
if (performance.now() - startedAt >= 650) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
return heights;
|
||||
});
|
||||
|
||||
expect(sampledHeights.length).toBeGreaterThan(1);
|
||||
expect(Math.min(...sampledHeights)).toBeGreaterThanOrEqual(baselineHeight - 1);
|
||||
await panel
|
||||
.getByTestId("scheduled-condition-timeline")
|
||||
.getByRole("button", { name: /SCADA 数据诊断/ })
|
||||
.first()
|
||||
.click();
|
||||
const workflowTitle = panel.getByText("SCADA 诊断流程", { exact: true });
|
||||
await expect(workflowTitle).toBeVisible();
|
||||
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
const timeline = panel.getByTestId("scheduled-condition-timeline");
|
||||
const runningCondition = timeline
|
||||
.locator('button[aria-pressed]')
|
||||
.filter({ hasText: "SCADA 数据诊断" })
|
||||
.filter({ hasText: "执行中" })
|
||||
.first();
|
||||
const executingWorkOrder = timeline
|
||||
.locator('button[aria-pressed]')
|
||||
.filter({ hasText: "系统工单:北辰分区阀门开度调整" });
|
||||
|
||||
await expectRunningStatusAnimations(runningCondition);
|
||||
await runningCondition.click();
|
||||
await expectRunningStatusAnimations(runningCondition);
|
||||
await expect(
|
||||
panel
|
||||
.getByText("SCADA 诊断流程", { exact: true })
|
||||
.locator("..")
|
||||
.locator(".status-badge .status-dot")
|
||||
).toHaveCount(0);
|
||||
|
||||
await expectRunningStatusAnimations(executingWorkOrder);
|
||||
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
|
||||
await executingWorkOrder.click();
|
||||
await expectRunningStatusAnimations(executingWorkOrder);
|
||||
});
|
||||
|
||||
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
const awaitingReplyWorkOrder = panel
|
||||
.getByTestId("scheduled-condition-timeline")
|
||||
.locator('button[aria-pressed]')
|
||||
.filter({ hasText: "系统工单:泵站边界复核复令" });
|
||||
|
||||
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
|
||||
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
|
||||
"data-status-tone",
|
||||
"warning"
|
||||
);
|
||||
await expect(awaitingReplyWorkOrder.locator(".status-glyph")).toHaveAttribute(
|
||||
"data-activity",
|
||||
"static"
|
||||
);
|
||||
await expect.poll(() => getAnimationName(awaitingReplyWorkOrder.locator(".status-glyph"))).toBe(
|
||||
"none"
|
||||
);
|
||||
|
||||
await awaitingReplyWorkOrder.click();
|
||||
await expect(panel.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.getByTestId("scheduled-condition-detail-scroll")
|
||||
.locator('.status-badge[data-status-tone="warning"]')
|
||||
.filter({ hasText: "待复令" })
|
||||
.first()
|
||||
).toHaveAttribute("data-status-tone", "warning");
|
||||
});
|
||||
|
||||
async function readScrollbarPresentation(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const scrollbarStyle = getComputedStyle(element, "::-webkit-scrollbar");
|
||||
const thumbStyle = getComputedStyle(element, "::-webkit-scrollbar-thumb");
|
||||
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
overflowY: style.overflowY,
|
||||
scrollbarColor: style.scrollbarColor,
|
||||
scrollbarWidth: style.scrollbarWidth,
|
||||
thumbBackground: thumbStyle.backgroundColor,
|
||||
thumbRadius: thumbStyle.borderRadius,
|
||||
webkitWidth: scrollbarStyle.width
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAcrylicComposition(panel: Locator) {
|
||||
const composition = await panel.evaluate((element) => {
|
||||
const rootStyle = getComputedStyle(element);
|
||||
@@ -59,3 +291,15 @@ async function expectAcrylicComposition(panel: Locator) {
|
||||
expect(composition.transform).toBe("none");
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
|
||||
async function expectRunningStatusAnimations(row: Locator) {
|
||||
const timelineGlyph = row.locator(".status-icon .status-glyph");
|
||||
|
||||
await expect(timelineGlyph).toHaveAttribute("data-activity", "loading");
|
||||
await expect(row.locator(".status-badge .status-dot")).toHaveCount(0);
|
||||
await expect.poll(() => getAnimationName(timelineGlyph)).toBe("status-spin");
|
||||
}
|
||||
|
||||
async function getAnimationName(locator: Locator) {
|
||||
return locator.evaluate((element) => getComputedStyle(element).animationName);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user