Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abef9ffabf | ||
|
|
db696eb4f1 | ||
|
|
229cb0def2 | ||
|
|
1fb950806a | ||
|
|
073832034a | ||
|
|
57c494e8bd | ||
|
|
4a589d7a43 | ||
|
|
d39e58196f | ||
|
|
b93999646c | ||
|
|
b91be09f5c | ||
|
|
602b39d60a | ||
|
|
5e7101bfee | ||
|
|
21dbfd82b6 | ||
|
|
760370c93f | ||
|
|
3db95c27b5 | ||
|
|
45f1ebee7e | ||
|
|
d2f2895c36 | ||
|
|
bfadc95f93 | ||
|
|
be7b4149f2 | ||
|
|
1068304da5 | ||
|
|
f58e0a70f4 | ||
|
|
d08cf2abc1 | ||
|
|
bdd5eff776 | ||
|
|
bdf4436899 | ||
|
|
d29792a5c5 | ||
|
|
d1e69ad59b | ||
|
|
7216ffd60f | ||
|
|
b1b68d2754 | ||
|
|
2c35f500df | ||
|
|
3ba62ceacb | ||
|
|
73896a893f | ||
|
|
f4318a9b9c | ||
|
|
ade88b4fbf | ||
|
|
f8c79a2af3 | ||
|
|
f0485ca86e |
@@ -1,3 +1,6 @@
|
||||
TJWATER_AUTH_MODE=required
|
||||
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Frontend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-test-publish-and-deploy:
|
||||
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||
with:
|
||||
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-frontend
|
||||
dockerfile: Dockerfile
|
||||
build_context: .
|
||||
test_target: build
|
||||
deploy_service: next-frontend
|
||||
deploy_host: 192.168.1.114
|
||||
secrets:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
This repository contains the Vite and React single-page frontend for the
|
||||
Agent-driven supply-network WebGIS. Runtime TypeScript lives under `src/`.
|
||||
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, deck.gl,
|
||||
SWR, Vitest, and Playwright.
|
||||
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, SWR,
|
||||
Vitest, and Playwright.
|
||||
|
||||
Keep this project browser-first. Do not add Next.js APIs, `"use client"`
|
||||
directives, or general backend-for-frontend routes. Agent reasoning and tool
|
||||
|
||||
+12
-4
@@ -1,17 +1,23 @@
|
||||
FROM node:24-alpine AS build
|
||||
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
|
||||
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
||||
ENV npm_config_registry=${NPM_REGISTRY}
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM caddy:2.10.2-alpine
|
||||
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
||||
|
||||
RUN apk add --no-cache jq nodejs
|
||||
ARG ALPINE_MIRROR=mirrors.aliyun.com
|
||||
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
|
||||
apk add --no-cache jq nodejs
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
|
||||
COPY --from=build /app/dist /srv
|
||||
@@ -19,5 +25,7 @@ COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-fr
|
||||
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以地图为主视图,集成供水管网图层、调度状态、事件处置、Agent 对话和受控前端动作。
|
||||
|
||||
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
|
||||
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图与流向效果基于 MapLibre GL,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -34,6 +34,9 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
||||
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
||||
|
||||
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
||||
|
||||
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||
|
||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||
|
||||
## 常用命令
|
||||
@@ -70,6 +77,17 @@ docker run --rm -p 8080:80 --env-file .env.local next-tjwater
|
||||
|
||||
启动后访问 `http://localhost:8080`。
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions 通过 `.gitea/workflows/package.yml` 复用
|
||||
`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev
|
||||
服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会
|
||||
提升为正式 `frontend` 服务。
|
||||
|
||||
仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和
|
||||
`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。生产运行时配置由 Dev 服务器的
|
||||
`env/frontend.env` 注入,不写入镜像或仓库 Secret。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
@@ -87,4 +105,4 @@ src/
|
||||
|
||||
## 构建说明
|
||||
|
||||
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
||||
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
set -eu
|
||||
|
||||
runtime_config=$(jq -cn \
|
||||
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
|
||||
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
||||
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
|
||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||
'{
|
||||
TJWATER_AUTH_MODE: $authMode,
|
||||
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||
TJWATER_MAP_URL: $mapUrl,
|
||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 状态语言
|
||||
|
||||
系统使用五种语义状态。业务模块保留自己的领域枚举,并在展示边界映射到共享
|
||||
`StatusTone`,不要让展示文案、接口字符串或 Tailwind 原色类直接决定状态样式。
|
||||
|
||||
| Tone | 用途 | 典型状态 |
|
||||
| --- | --- | --- |
|
||||
| `neutral` | 尚未开始或不再活动 | 预约、草案、待办、取消、中止、跳过、未分级 |
|
||||
| `info` | 正在发生的过程 | 执行中、提交中、加载中、连接中 |
|
||||
| `success` | 已完成或健康 | 完成、在线、正常、已批准 |
|
||||
| `warning` | 需要关注但未达到异常 | 关注、降级、待复核、待复令、待确认 |
|
||||
| `danger` | 已异常或操作失败 | 异常、失败、离线、高风险、事故、权限拒绝 |
|
||||
|
||||
## 报告风险
|
||||
|
||||
报告风险固定为三档:
|
||||
|
||||
- `normal` 映射 `success`,显示“正常”。
|
||||
- `attention` 映射 `warning`,显示“关注”。
|
||||
- `critical` 映射 `danger`,显示“异常”。
|
||||
|
||||
`info` 只表示过程,不参与风险等级。不得把 `attention` 合并到正常或异常。
|
||||
|
||||
## 表达规则
|
||||
|
||||
- 状态标签使用 `StatusBadge`,小型指示使用 `StatusDot`,图标节点使用 `StatusIcon`。
|
||||
- 大面积容器只使用柔和背景,禁止实心红色或橙色状态卡片。
|
||||
- `warning` 和 `danger` 必须同时提供文字或图标,不能只依赖颜色。
|
||||
- 只有进行中的状态可以使用 `live` 或 `loading` 动效。
|
||||
- “复令”是提交处置结果的动作,执行完成但尚未提交时显示“待复令”,使用静态
|
||||
`warning`;不得继续显示为“执行中”或提前显示为完成态。
|
||||
- 动效必须在 `prefers-reduced-motion` 下停止。
|
||||
- 蓝色选中态属于交互反馈,不作为业务风险状态。
|
||||
- 紫色只表示 Agent 身份,不表示未知或更高风险。
|
||||
|
||||
## 地图状态
|
||||
|
||||
地图与面板共享语义,但保留独立色值以适应浅色底图、影像底图和白色 casing。
|
||||
新增地图状态时,应先补充 `map-colors.ts`,再通过对比度测试验证,不要在图层定义中
|
||||
写入新的颜色字面量。
|
||||
+7
-1
@@ -3,7 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TJWater WebGIS Agent</title>
|
||||
<link
|
||||
rel="preload"
|
||||
as="fetch"
|
||||
href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<title>供水管网</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+5
-6
@@ -6,7 +6,8 @@
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && pnpm build:tts",
|
||||
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
|
||||
"build:verify-css": "node scripts/verify-production-css.mjs",
|
||||
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -18,9 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.9",
|
||||
"@deck.gl/core": "^9.3.7",
|
||||
"@deck.gl/layers": "^9.3.7",
|
||||
"@deck.gl/mapbox": "^9.3.7",
|
||||
"@fontsource-variable/noto-sans-sc": "5.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.14",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
@@ -44,6 +43,7 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"edge-tts-ts": "^1.0.0",
|
||||
"katex": "^0.17.0",
|
||||
"keycloak-js": "26.2.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
@@ -72,14 +72,13 @@
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"esbuild": "^0.28.0",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"msw": "^2.6.8",
|
||||
"playwright": "^1.61.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -2,18 +2,25 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
|
||||
const runningInCi = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||
fullyParallel: true,
|
||||
reporter: "html",
|
||||
forbidOnly: runningInCi,
|
||||
retries: runningInCi ? 1 : 0,
|
||||
workers: runningInCi ? 2 : undefined,
|
||||
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
|
||||
use: {
|
||||
baseURL,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
deviceScaleFactor: 1,
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false
|
||||
},
|
||||
|
||||
Generated
+17
-374
@@ -11,15 +11,9 @@ importers:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.34(react@19.2.7)(zod@3.25.76)
|
||||
'@deck.gl/core':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7
|
||||
'@deck.gl/layers':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))
|
||||
'@deck.gl/mapbox':
|
||||
specifier: ^9.3.7
|
||||
version: 9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)
|
||||
'@fontsource-variable/noto-sans-sc':
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -89,6 +83,9 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
keycloak-js:
|
||||
specifier: 26.2.4
|
||||
version: 26.2.4
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@19.2.7)
|
||||
@@ -189,9 +186,6 @@ importers:
|
||||
msw:
|
||||
specifier: ^2.6.8
|
||||
version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
|
||||
playwright:
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.9.5
|
||||
@@ -454,24 +448,6 @@ packages:
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@deck.gl/core@9.3.7':
|
||||
resolution: {integrity: sha512-l+YPm1I0Fwj+b7xsu8FvyDT5MGPgEPJe2cQJUpQNgG742b2goB4tyYpteUhNAOeVo1gmenvqv36ZJGNsGxipJA==}
|
||||
|
||||
'@deck.gl/layers@9.3.7':
|
||||
resolution: {integrity: sha512-T2CnkEU1QEqmusrSdot7+8Nl09gzntG5DvhaogyBbTjmABhTh4rUnS6dLOjf5/ju3aJ+Q58/FvDzgBBcK4V7kg==}
|
||||
peerDependencies:
|
||||
'@deck.gl/core': ~9.3.0
|
||||
'@loaders.gl/core': ^4.4.3
|
||||
'@luma.gl/core': ~9.3.3
|
||||
'@luma.gl/engine': ~9.3.3
|
||||
|
||||
'@deck.gl/mapbox@9.3.7':
|
||||
resolution: {integrity: sha512-hamgwBS2BbB8DHOjYWwPMrFki+KhcedjoHv/9cUpQHnjfXUswzmyhAURuyowO/tAVJb3vF+CmMzaDVUTlI40Sw==}
|
||||
peerDependencies:
|
||||
'@deck.gl/core': ~9.3.0
|
||||
'@luma.gl/core': ~9.3.3
|
||||
'@math.gl/web-mercator': ^4.1.0
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -681,6 +657,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0':
|
||||
resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -758,49 +737,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@loaders.gl/core@4.4.3':
|
||||
resolution: {integrity: sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==}
|
||||
|
||||
'@loaders.gl/images@4.4.3':
|
||||
resolution: {integrity: sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@loaders.gl/loader-utils@4.4.3':
|
||||
resolution: {integrity: sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==}
|
||||
|
||||
'@loaders.gl/schema-utils@4.4.3':
|
||||
resolution: {integrity: sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@loaders.gl/schema@4.4.3':
|
||||
resolution: {integrity: sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==}
|
||||
|
||||
'@loaders.gl/worker-utils@4.4.3':
|
||||
resolution: {integrity: sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==}
|
||||
peerDependencies:
|
||||
'@loaders.gl/core': ~4.4.0
|
||||
|
||||
'@luma.gl/core@9.3.6':
|
||||
resolution: {integrity: sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==}
|
||||
|
||||
'@luma.gl/engine@9.3.6':
|
||||
resolution: {integrity: sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
'@luma.gl/shadertools': ~9.3.0
|
||||
|
||||
'@luma.gl/shadertools@9.3.6':
|
||||
resolution: {integrity: sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
|
||||
'@luma.gl/webgl@9.3.6':
|
||||
resolution: {integrity: sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==}
|
||||
peerDependencies:
|
||||
'@luma.gl/core': ~9.3.0
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
|
||||
hasBin: true
|
||||
@@ -829,21 +765,6 @@ packages:
|
||||
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
|
||||
hasBin: true
|
||||
|
||||
'@math.gl/core@4.1.0':
|
||||
resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
|
||||
|
||||
'@math.gl/polygon@4.1.0':
|
||||
resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==}
|
||||
|
||||
'@math.gl/sun@4.1.0':
|
||||
resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==}
|
||||
|
||||
'@math.gl/types@4.1.0':
|
||||
resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==}
|
||||
|
||||
'@math.gl/web-mercator@4.1.0':
|
||||
resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==}
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
@@ -871,15 +792,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@probe.gl/env@4.1.1':
|
||||
resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==}
|
||||
|
||||
'@probe.gl/log@4.1.1':
|
||||
resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==}
|
||||
|
||||
'@probe.gl/stats@4.1.1':
|
||||
resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==}
|
||||
|
||||
'@radix-ui/number@1.1.2':
|
||||
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
|
||||
|
||||
@@ -1708,12 +1620,6 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/command-line-args@5.2.3':
|
||||
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
|
||||
|
||||
'@types/command-line-usage@5.0.4':
|
||||
resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -1849,9 +1755,6 @@ packages:
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/pbf@3.0.5':
|
||||
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
|
||||
|
||||
@@ -2084,10 +1987,6 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
apache-arrow@21.1.0:
|
||||
resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==}
|
||||
hasBin: true
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -2102,10 +2001,6 @@ packages:
|
||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
array-back@6.2.3:
|
||||
resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2150,10 +2045,6 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2205,19 +2096,6 @@ packages:
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
command-line-args@6.0.2:
|
||||
resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==}
|
||||
engines: {node: '>=12.20'}
|
||||
peerDependencies:
|
||||
'@75lb/nature': latest
|
||||
peerDependenciesMeta:
|
||||
'@75lb/nature':
|
||||
optional: true
|
||||
|
||||
command-line-usage@7.0.4:
|
||||
resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2475,9 +2353,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
earcut@2.2.4:
|
||||
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
|
||||
|
||||
earcut@3.2.3:
|
||||
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
|
||||
|
||||
@@ -2660,15 +2535,6 @@ packages:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
find-replace@5.0.2:
|
||||
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@75lb/nature': latest
|
||||
peerDependenciesMeta:
|
||||
'@75lb/nature':
|
||||
optional: true
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2677,9 +2543,6 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatbuffers@25.9.23:
|
||||
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
|
||||
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
@@ -2968,10 +2831,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bignum@0.0.3:
|
||||
resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@@ -3003,6 +2862,9 @@ packages:
|
||||
kdbush@4.1.0:
|
||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||
|
||||
keycloak-js@26.2.4:
|
||||
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -3104,9 +2966,6 @@ packages:
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3355,9 +3214,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mjolnir.js@3.0.1:
|
||||
resolution: {integrity: sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ==}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
|
||||
|
||||
@@ -3799,10 +3655,6 @@ packages:
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
table-layout@4.1.1:
|
||||
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3897,10 +3749,6 @@ packages:
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
typical@7.3.0:
|
||||
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
@@ -4114,10 +3962,6 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wordwrapjs@5.1.1:
|
||||
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4485,51 +4329,6 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@deck.gl/core@9.3.7':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@luma.gl/webgl': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/sun': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
gl-matrix: 3.4.4
|
||||
mjolnir.js: 3.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@deck.gl/layers@9.3.7(@deck.gl/core@9.3.7)(@loaders.gl/core@4.4.3)(@luma.gl/core@9.3.6)(@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)))':
|
||||
dependencies:
|
||||
'@deck.gl/core': 9.3.7
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/images': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/engine': 9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@mapbox/tiny-sdf': 2.2.0
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/polygon': 4.1.0
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
'@types/geojson': 7946.0.16
|
||||
earcut: 2.2.4
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@deck.gl/mapbox@9.3.7(@deck.gl/core@9.3.7)(@luma.gl/core@9.3.6)(@math.gl/web-mercator@4.1.0)':
|
||||
dependencies:
|
||||
'@deck.gl/core': 9.3.7
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/web-mercator': 4.1.0
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
@@ -4671,6 +4470,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@fontsource-variable/noto-sans-sc@5.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -4741,82 +4542,6 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@loaders.gl/core@4.4.3':
|
||||
dependencies:
|
||||
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@loaders.gl/schema-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@probe.gl/log': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/images@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/loader-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/loader-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@loaders.gl/worker-utils': 4.4.3(@loaders.gl/core@4.4.3)
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
- '@loaders.gl/core'
|
||||
|
||||
'@loaders.gl/schema-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
'@loaders.gl/schema': 4.4.3
|
||||
'@types/geojson': 7946.0.16
|
||||
apache-arrow: 21.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/schema@4.4.3':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
apache-arrow: 21.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
'@loaders.gl/worker-utils@4.4.3(@loaders.gl/core@4.4.3)':
|
||||
dependencies:
|
||||
'@loaders.gl/core': 4.4.3
|
||||
|
||||
'@luma.gl/core@9.3.6':
|
||||
dependencies:
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
|
||||
'@luma.gl/engine@9.3.6(@luma.gl/core@9.3.6)(@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6))':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@luma.gl/shadertools': 9.3.6(@luma.gl/core@9.3.6)
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/log': 4.1.1
|
||||
'@probe.gl/stats': 4.1.1
|
||||
|
||||
'@luma.gl/shadertools@9.3.6(@luma.gl/core@9.3.6)':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/core': 4.1.0
|
||||
'@math.gl/types': 4.1.0
|
||||
|
||||
'@luma.gl/webgl@9.3.6(@luma.gl/core@9.3.6)':
|
||||
dependencies:
|
||||
'@luma.gl/core': 9.3.6
|
||||
'@math.gl/types': 4.1.0
|
||||
'@probe.gl/env': 4.1.1
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
dependencies:
|
||||
get-stream: 6.0.1
|
||||
@@ -4846,22 +4571,6 @@ snapshots:
|
||||
rw: 1.3.3
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
'@math.gl/core@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/types': 4.1.0
|
||||
|
||||
'@math.gl/polygon@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/core': 4.1.0
|
||||
|
||||
'@math.gl/sun@4.1.0': {}
|
||||
|
||||
'@math.gl/types@4.1.0': {}
|
||||
|
||||
'@math.gl/web-mercator@4.1.0':
|
||||
dependencies:
|
||||
'@math.gl/core': 4.1.0
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
@@ -4894,14 +4603,6 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@probe.gl/env@4.1.1': {}
|
||||
|
||||
'@probe.gl/log@4.1.1':
|
||||
dependencies:
|
||||
'@probe.gl/env': 4.1.1
|
||||
|
||||
'@probe.gl/stats@4.1.1': {}
|
||||
|
||||
'@radix-ui/number@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.6': {}
|
||||
@@ -5542,6 +5243,7 @@ snapshots:
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
dependencies:
|
||||
@@ -5656,10 +5358,6 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/command-line-args@5.2.3': {}
|
||||
|
||||
'@types/command-line-usage@5.0.4': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -5821,8 +5519,6 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/pbf@3.0.5': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
@@ -6001,20 +5697,6 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
apache-arrow@21.1.0:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
'@types/command-line-args': 5.2.3
|
||||
'@types/command-line-usage': 5.0.4
|
||||
'@types/node': 24.13.3
|
||||
command-line-args: 6.0.2
|
||||
command-line-usage: 7.0.4
|
||||
flatbuffers: 25.9.23
|
||||
json-bignum: 0.0.3
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@75lb/nature'
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
@@ -6027,8 +5709,6 @@ snapshots:
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
|
||||
array-back@6.2.3: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -6065,10 +5745,6 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -6120,20 +5796,6 @@ snapshots:
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
command-line-args@6.0.2:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
find-replace: 5.0.2
|
||||
lodash.camelcase: 4.3.0
|
||||
typical: 7.3.0
|
||||
|
||||
command-line-usage@7.0.4:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
chalk-template: 0.4.0
|
||||
table-layout: 4.1.1
|
||||
typical: 7.3.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -6402,8 +6064,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
earcut@2.2.4: {}
|
||||
|
||||
earcut@3.2.3: {}
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
|
||||
@@ -6615,8 +6275,6 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
||||
find-replace@5.0.2: {}
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -6627,8 +6285,6 @@ snapshots:
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flatbuffers@25.9.23: {}
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
form-data@4.0.6:
|
||||
@@ -6973,8 +6629,6 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bignum@0.0.3: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
@@ -6997,6 +6651,8 @@ snapshots:
|
||||
|
||||
kdbush@4.1.0: {}
|
||||
|
||||
keycloak-js@26.2.4: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -7069,8 +6725,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
@@ -7586,8 +7240,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mjolnir.js@3.0.1: {}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
@@ -8091,11 +7743,6 @@ snapshots:
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
table-layout@4.1.1:
|
||||
dependencies:
|
||||
array-back: 6.2.3
|
||||
wordwrapjs: 5.1.1
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
@@ -8186,8 +7833,6 @@ snapshots:
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
typical@7.3.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -8369,8 +8014,6 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
globalThis.__TJWATER_CONFIG__ = {};
|
||||
globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { stdout } from "node:process";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const assetsDirectory = fileURLToPath(new URL("../dist/assets/", import.meta.url));
|
||||
const assetNames = await readdir(assetsDirectory);
|
||||
const stylesheetName = assetNames.find((name) => /^index-.*\.css$/.test(name));
|
||||
|
||||
if (!stylesheetName) {
|
||||
throw new Error("Production CSS verification failed: index stylesheet was not emitted.");
|
||||
}
|
||||
|
||||
const stylesheet = await readFile(path.join(assetsDirectory, stylesheetName), "utf8");
|
||||
const selector = ".agent-panel-shell.acrylic-panel";
|
||||
const selectorOffset = stylesheet.indexOf(selector);
|
||||
const ruleStart = stylesheet.indexOf("{", selectorOffset);
|
||||
const ruleEnd = stylesheet.indexOf("}", ruleStart);
|
||||
|
||||
if (selectorOffset < 0 || ruleStart < 0 || ruleEnd < 0) {
|
||||
throw new Error(`Production CSS verification failed: ${selector} rule is missing.`);
|
||||
}
|
||||
|
||||
const declarations = stylesheet
|
||||
.slice(ruleStart + 1, ruleEnd)
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim());
|
||||
|
||||
if (!declarations.some((declaration) => declaration.startsWith("backdrop-filter:"))) {
|
||||
throw new Error(
|
||||
"Production CSS verification failed: the standard backdrop-filter declaration was removed."
|
||||
);
|
||||
}
|
||||
|
||||
stdout.write(`Verified standard backdrop-filter in ${stylesheetName}.\n`);
|
||||
+15
-7
@@ -80,7 +80,7 @@ test("preserves compact typography in header and agent controls", async ({ page
|
||||
await expect(approvalControl).toHaveCSS("font-size", "12px");
|
||||
await expect(approvalControl).toHaveCSS("line-height", "16px");
|
||||
await expect(suggestion).toHaveCSS("font-size", "12px");
|
||||
await expect(suggestion).toHaveCSS("font-weight", "500");
|
||||
await expect(suggestion).toHaveCSS("font-weight", "600");
|
||||
});
|
||||
|
||||
test("lets interactive utilities override surface materials", async ({ page }) => {
|
||||
@@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
||||
expect(hoverBackground).not.toBe(restingBackground);
|
||||
});
|
||||
|
||||
test("preserves v3 menu and panel styling through Tailwind v4", async ({ page }) => {
|
||||
test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
|
||||
@@ -112,10 +112,18 @@ test("preserves v3 menu and panel styling through Tailwind v4", async ({ page })
|
||||
expect(menuTransformOrigin).not.toBe("50% 50%");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
|
||||
const commandShadow = await agentCommandControl.evaluate(
|
||||
(element) => window.getComputedStyle(element).boxShadow
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const navigationMaterial = await navigation.evaluate(
|
||||
(element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor
|
||||
};
|
||||
}
|
||||
);
|
||||
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
|
||||
expect(commandShadow).not.toContain("0px 1px 3px 0px");
|
||||
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||
expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
|
||||
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
|
||||
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
+7
-2
@@ -1,11 +1,16 @@
|
||||
import { MapToaster } from "@/features/map/core";
|
||||
import { MapWorkbenchPage } from "@/features/workbench";
|
||||
import type { Authentication } from "@/shared/auth/keycloak-auth";
|
||||
import { AppProviders } from "./providers";
|
||||
|
||||
export function App() {
|
||||
export function App({ authentication }: { authentication: Authentication }) {
|
||||
return (
|
||||
<AppProviders>
|
||||
<MapWorkbenchPage />
|
||||
<MapWorkbenchPage
|
||||
user={authentication.user ?? undefined}
|
||||
onLogout={authentication.logout}
|
||||
getAccessToken={authentication.getAccessToken}
|
||||
/>
|
||||
<MapToaster />
|
||||
</AppProviders>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Droplets, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function AuthStartupScreen({ error }: { error?: boolean }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
|
||||
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
|
||||
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
|
||||
<Droplets size={22} aria-hidden="true" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-lg font-semibold">
|
||||
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
|
||||
</p>
|
||||
{error ? (
|
||||
<Button className="mt-5" onClick={() => window.location.reload()}>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
重新连接
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||
|
||||
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
if (!init) throw new Error("Expected Agent request init");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("streams session events from the backend SSE endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
|
||||
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
options: AgentApiClientOptions = {}
|
||||
): AgentApiClient {
|
||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||
let activeBaseUrl = candidates[0] ?? "";
|
||||
const setActiveBaseUrl = (baseUrl: string) => {
|
||||
activeBaseUrl = baseUrl;
|
||||
};
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
setActiveBaseUrl,
|
||||
path,
|
||||
init,
|
||||
options.getAccessToken
|
||||
);
|
||||
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
||||
const response = await request(path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
};
|
||||
|
||||
return {
|
||||
async createSession() {
|
||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/session", {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
"/sessions"
|
||||
);
|
||||
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async getFrontendActionRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
||||
return requestJson<unknown>("/frontend-action-registry");
|
||||
},
|
||||
|
||||
async submitFrontendActionResult(sessionId, actionId, result) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||
body: JSON.stringify(result)
|
||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
return;
|
||||
}
|
||||
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
},
|
||||
|
||||
async getModels() {
|
||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/models");
|
||||
const payload = await requestJson<unknown>("/models");
|
||||
return toModelsResponse(payload);
|
||||
},
|
||||
|
||||
async getUiRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/ui-registry");
|
||||
return requestJson<unknown>("/ui-registry");
|
||||
},
|
||||
|
||||
async resolveRenderRef(renderRef, sessionId) {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
return requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
return requestJson<unknown>(
|
||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
async replyPermission(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async rejectQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async abort(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/abort", {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback<T>(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function fetchWithFallback(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
const accessToken = await getAccessToken?.();
|
||||
const requestInit = accessToken
|
||||
? {
|
||||
...init,
|
||||
headers: withBearerToken(init?.headers, accessToken)
|
||||
}
|
||||
: init;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||
}
|
||||
|
||||
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
|
||||
const headers = new Headers(headersInit);
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function shouldFallbackOnHttpStatus(status: number) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
@@ -21,8 +20,7 @@ export function AgentCollapsedRail({
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
|
||||
>
|
||||
<AgentPersona className="h-11 w-11" state={personaState} />
|
||||
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
DatabaseZap,
|
||||
@@ -15,10 +16,11 @@ import {
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Volume2
|
||||
Volume2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import {
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentConnectionStatus,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
@@ -77,7 +81,17 @@ import type {
|
||||
AgentUiResult
|
||||
} from "../types";
|
||||
|
||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||
|
||||
export type AgentWorkspaceContextItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "info" | "warning";
|
||||
};
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
presentation?: AgentCommandPanelPresentation;
|
||||
collapsing?: boolean;
|
||||
personaState?: PersonaState;
|
||||
sessionTitle?: string;
|
||||
@@ -85,6 +99,7 @@ type AgentCommandPanelProps = {
|
||||
sessionHistoryLoading?: boolean;
|
||||
activeSessionId?: string | null;
|
||||
statusLabel?: string;
|
||||
connectionStatus?: AgentConnectionStatus;
|
||||
streaming?: boolean;
|
||||
messages?: AgentChatMessage[];
|
||||
streamRenderState?: AgentStreamRenderState;
|
||||
@@ -92,6 +107,7 @@ type AgentCommandPanelProps = {
|
||||
selectedModel?: string;
|
||||
approvalMode?: AgentApprovalMode;
|
||||
uiResults?: AgentUiResult[];
|
||||
workspaceContext?: AgentWorkspaceContextItem[];
|
||||
onCollapse: () => void;
|
||||
onRefreshHistory?: () => Promise<void> | void;
|
||||
onStartNewSession?: () => Promise<void> | void;
|
||||
@@ -127,6 +143,7 @@ const AGENT_CAPABILITIES = [
|
||||
] as const;
|
||||
|
||||
export function AgentCommandPanel({
|
||||
presentation = "desktop-dock",
|
||||
collapsing = false,
|
||||
personaState,
|
||||
sessionTitle = "新对话",
|
||||
@@ -134,6 +151,7 @@ export function AgentCommandPanel({
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
connectionStatus = "connecting",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
streamRenderState = {},
|
||||
@@ -141,6 +159,7 @@ export function AgentCommandPanel({
|
||||
selectedModel = "",
|
||||
approvalMode = "request",
|
||||
uiResults = [],
|
||||
workspaceContext = [],
|
||||
onCollapse,
|
||||
onRefreshHistory,
|
||||
onStartNewSession,
|
||||
@@ -159,6 +178,12 @@ export function AgentCommandPanel({
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const historyPanelId = useId();
|
||||
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const historyPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
|
||||
const hasConversation = messages.length > 0 || streaming;
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const {
|
||||
speechState,
|
||||
@@ -194,6 +219,46 @@ export function AgentCommandPanel({
|
||||
}
|
||||
}, [historyOpen, onRefreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
historyPanelRef.current?.contains(target) ||
|
||||
historyTriggerRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
const closeHistoryOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setHistoryOpen(false);
|
||||
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||
document.addEventListener("keydown", closeHistoryOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||||
document.removeEventListener("keydown", closeHistoryOnEscape);
|
||||
};
|
||||
}, [historyOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
stopSpeech();
|
||||
}, [activeSessionId, stopSpeech]);
|
||||
@@ -213,138 +278,238 @@ export function AgentCommandPanel({
|
||||
setPrompt(nextPrompt);
|
||||
});
|
||||
};
|
||||
const historyPanel = (
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
);
|
||||
const operationalBrief = (
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<aside
|
||||
aria-label="Agent 命令面板"
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||
"agent-panel-shell",
|
||||
"acrylic-panel border",
|
||||
"rounded-2xl",
|
||||
presentation === "desktop-dock"
|
||||
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
|
||||
: desktopFloating
|
||||
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<header className="agent-panel-header">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
<Agent
|
||||
className={cn(
|
||||
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
|
||||
floatingPresentation && "gap-1 overflow-visible p-1"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-30 h-16 shrink-0">
|
||||
<header
|
||||
className={cn(
|
||||
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
|
||||
floatingPresentation && "agent-panel-integrated-header",
|
||||
presentation === "mobile-sheet" && "agent-panel-header-mobile",
|
||||
historyOpen && "agent-panel-header-expanded"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
</p>
|
||||
<StatusBadge
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
icon={
|
||||
<StatusDot
|
||||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||||
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{agentConnectionMetadata[connectionStatus].label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
getAgentConnectionClassName(statusLabel)
|
||||
)}
|
||||
/>
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref={historyTriggerRef}
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyPanelId}
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{presentation === "mobile-sheet" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭 Agent 面板"
|
||||
title="关闭 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
ref={historyPanelRef}
|
||||
key="history"
|
||||
id={historyPanelId}
|
||||
role="region"
|
||||
aria-label="Agent 历史记录"
|
||||
className="agent-panel-floating-acrylic agent-panel-history-extension"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{historyPanel}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{workspaceContext.length > 0 ? (
|
||||
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
|
||||
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
|
||||
{workspaceContext.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-1",
|
||||
item.tone === "info" && "bg-blue-50 text-blue-700",
|
||||
item.tone === "warning" && "bg-amber-50 text-amber-700",
|
||||
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
|
||||
)}
|
||||
title={`${item.label}:${item.value}`}
|
||||
>
|
||||
{item.label} · <strong className="font-semibold">{item.value}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{floatingPresentation ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
{hasConversation && !historyOpen ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
aria-hidden={historyOpen}
|
||||
className={cn(
|
||||
"space-y-2 px-3 pb-3",
|
||||
historyOpen && "invisible pointer-events-none"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
{operationalBrief}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<Conversation
|
||||
className={cn(
|
||||
"agent-panel-conversation min-h-0",
|
||||
floatingPresentation && "agent-panel-conversation-canvas"
|
||||
)}
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
||||
className={cn(
|
||||
"gap-4 p-3",
|
||||
messages.length === 0 && "min-h-full",
|
||||
floatingPresentation && "agent-panel-conversation-content",
|
||||
floatingPresentation &&
|
||||
hasConversation &&
|
||||
"agent-panel-conversation-content-has-brief",
|
||||
floatingPresentation &&
|
||||
!hasConversation &&
|
||||
"agent-panel-conversation-content-empty"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
@@ -373,7 +538,7 @@ export function AgentCommandPanel({
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState />
|
||||
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -384,14 +549,32 @@ export function AgentCommandPanel({
|
||||
activeSessionId={activeSessionId}
|
||||
messages={messages}
|
||||
scrollRequestId={scrollRequestId}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<ConversationScrollButton
|
||||
className={cn(
|
||||
"agent-panel-floating-acrylic agent-conversation-scroll-button",
|
||||
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
|
||||
)}
|
||||
/>
|
||||
<ConversationScrollButton className="bottom-3" />
|
||||
</Conversation>
|
||||
</AgentContent>
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"agent-panel-band relative",
|
||||
floatingPresentation
|
||||
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||||
: "border-t border-slate-200 p-3"
|
||||
)}
|
||||
>
|
||||
{!hasConversation ? (
|
||||
<div
|
||||
className={cn(
|
||||
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
|
||||
floatingPresentation && "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Suggestions
|
||||
aria-label="推荐问题"
|
||||
role="group"
|
||||
@@ -411,7 +594,7 @@ export function AgentCommandPanel({
|
||||
</div>
|
||||
) : null}
|
||||
<PromptInput
|
||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||||
onSubmit={(message) => {
|
||||
const nextPrompt = message.text.trim();
|
||||
if (nextPrompt) {
|
||||
@@ -423,7 +606,7 @@ export function AgentCommandPanel({
|
||||
<PromptInputTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
className="min-h-14 text-sm"
|
||||
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
@@ -435,7 +618,7 @@ export function AgentCommandPanel({
|
||||
onValueChange={onApprovalModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
@@ -458,7 +641,13 @@ export function AgentCommandPanel({
|
||||
status={streaming ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
>
|
||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
||||
{streaming ? undefined : (
|
||||
<SendHorizontal
|
||||
size={15}
|
||||
style={{ transform: "rotate(-45deg)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
@@ -497,29 +686,38 @@ function VoiceInputButton({
|
||||
aria-pressed={isListening}
|
||||
title={label}
|
||||
className={cn(
|
||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
|
||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
|
||||
"border-transparent bg-transparent shadow-none",
|
||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||
"hover:bg-slate-50 hover:text-blue-700",
|
||||
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||||
"disabled:cursor-not-allowed disabled:opacity-45",
|
||||
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||
isListening
|
||||
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
|
||||
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
|
||||
>
|
||||
{isListening ? (
|
||||
<motion.span
|
||||
data-slot="voice-input-pulse"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={
|
||||
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
|
||||
reduceMotion
|
||||
? { opacity: 0.38, scale: 1 }
|
||||
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
|
||||
}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
|
||||
reduceMotion
|
||||
? { duration: 0.16 }
|
||||
: {
|
||||
duration: 1.7,
|
||||
ease: "easeOut",
|
||||
repeat: Infinity,
|
||||
times: [0, 0.12, 1]
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -539,7 +737,7 @@ function VoiceInputButton({
|
||||
{VOICE_WAVE_BARS.map((bar) => (
|
||||
<motion.span
|
||||
key={bar.height}
|
||||
className="w-px rounded-full bg-red-500"
|
||||
className="w-px rounded-full bg-[var(--status-mark)]"
|
||||
style={{ height: bar.height }}
|
||||
animate={
|
||||
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||
@@ -598,7 +796,8 @@ function ApprovalModeSelect({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
disabled={disabled || !onValueChange}
|
||||
aria-label="权限批准模式"
|
||||
>
|
||||
@@ -639,17 +838,24 @@ function ApprovalModeSelect({
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
|
||||
!requestApproval &&
|
||||
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
|
||||
)}
|
||||
onSelect={() => onValueChange?.("always")}
|
||||
>
|
||||
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||
<ShieldCheck
|
||||
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||||
</span>
|
||||
{!requestApproval ? (
|
||||
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||
<CheckCircle2
|
||||
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -659,29 +865,36 @@ function ApprovalModeSelect({
|
||||
|
||||
type AgentConversationScrollSnapshot = {
|
||||
activeSessionId?: string | null;
|
||||
lastMessageRevision: string;
|
||||
lastUserMessageId: string | null;
|
||||
scrollRequestId: number;
|
||||
streaming: boolean;
|
||||
};
|
||||
|
||||
function AgentConversationScrollManager({
|
||||
activeSessionId,
|
||||
messages,
|
||||
scrollRequestId
|
||||
scrollRequestId,
|
||||
streaming
|
||||
}: {
|
||||
activeSessionId?: string | null;
|
||||
messages: AgentChatMessage[];
|
||||
scrollRequestId: number;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
||||
const lastUserMessageId = getLastUserMessageId(messages);
|
||||
const lastMessageRevision = getLastMessageRevision(messages);
|
||||
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
||||
() => ({
|
||||
activeSessionId,
|
||||
lastMessageRevision,
|
||||
lastUserMessageId,
|
||||
scrollRequestId
|
||||
scrollRequestId,
|
||||
streaming
|
||||
}),
|
||||
[activeSessionId, lastUserMessageId, scrollRequestId]
|
||||
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -693,7 +906,10 @@ function AgentConversationScrollManager({
|
||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||
(Boolean(snapshot.lastUserMessageId) &&
|
||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId) ||
|
||||
(snapshot.streaming &&
|
||||
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
|
||||
previousSnapshot.streaming !== snapshot.streaming;
|
||||
|
||||
if (!forceScroll) {
|
||||
return;
|
||||
@@ -713,6 +929,19 @@ function AgentConversationScrollManager({
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLastMessageRevision(messages: AgentChatMessage[]) {
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
if (!lastMessage) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const progressRevision =
|
||||
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
|
||||
|
||||
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
|
||||
}
|
||||
|
||||
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "user") {
|
||||
@@ -746,7 +975,8 @@ function AgentModelSelect({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
size="sm"
|
||||
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
disabled={!onValueChange}
|
||||
aria-label="选择 Agent 模型"
|
||||
>
|
||||
@@ -801,13 +1031,11 @@ function AgentModelSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function getAgentConnectionLabel(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
|
||||
}
|
||||
|
||||
function getAgentConnectionClassName(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
|
||||
}
|
||||
const agentConnectionMetadata = {
|
||||
connecting: { label: "连接中", tone: "info" },
|
||||
online: { label: "在线", tone: "success" },
|
||||
offline: { label: "离线", tone: "danger" }
|
||||
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
|
||||
|
||||
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
||||
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
|
||||
@@ -875,50 +1103,66 @@ function AgentReadyMark() {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState() {
|
||||
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
|
||||
className="mx-auto w-full max-w-[360px]"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className="px-4 py-5">
|
||||
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
|
||||
<div
|
||||
className={cn(
|
||||
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
|
||||
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||||
)}
|
||||
>
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className="text-balance text-lg font-semibold leading-7 text-slate-900"
|
||||
className={cn(
|
||||
"text-balance font-semibold text-slate-950",
|
||||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||
)}
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
|
||||
<p
|
||||
className={cn(
|
||||
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||||
compact ? "mt-3" : "mt-5"
|
||||
)}
|
||||
>
|
||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
|
||||
className={cn(
|
||||
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
|
||||
compact && "hidden"
|
||||
)}
|
||||
aria-label="Agent 分析能力"
|
||||
>
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||||
<li
|
||||
key={label}
|
||||
className={cn(
|
||||
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
|
||||
index % 2 === 0 && "border-r border-slate-200/70",
|
||||
index < 2 && "border-b border-slate-200/70"
|
||||
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
|
||||
index % 2 === 0 && "border-r border-slate-300/80",
|
||||
index < 2 && "border-b border-slate-300/80"
|
||||
)}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
|
||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
|
||||
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -980,9 +1224,10 @@ function BackendMessageList({
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
||||
message.role === "user" &&
|
||||
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
|
||||
message.role === "assistant" &&
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -1168,21 +1413,16 @@ function AgentSpeechMessage({
|
||||
<motion.button
|
||||
key="speech-selection-action"
|
||||
type="button"
|
||||
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
||||
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
||||
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={speakFromSelection}
|
||||
>
|
||||
@@ -1270,20 +1510,21 @@ function SpeechIconButton({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
|
||||
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
|
||||
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
|
||||
tone === "primary" && "text-blue-600",
|
||||
tone === "danger" && "text-red-600"
|
||||
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
@@ -73,30 +75,35 @@ export function AgentHistoryPanel({
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={loading && sessions.length === 0}
|
||||
loading={loading && sessions.length > 0}
|
||||
loadingLabel="正在刷新 Agent 历史记录"
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
||||
<motion.div
|
||||
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -119,8 +126,10 @@ export function AgentHistoryPanel({
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||
active && !editing
|
||||
? "agent-history-item-active border border-blue-200"
|
||||
: "border border-transparent"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -130,8 +139,18 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
||||
<motion.div
|
||||
key="confirm-delete"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<span
|
||||
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||
title={session.title}
|
||||
>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
@@ -153,7 +172,7 @@ export function AgentHistoryPanel({
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 rounded-lg bg-white text-xs"
|
||||
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
@@ -168,7 +187,7 @@ export function AgentHistoryPanel({
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
className="agent-history-session-trigger min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -176,7 +195,10 @@ export function AgentHistoryPanel({
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
|
||||
<span
|
||||
className="block truncate text-xs font-semibold text-slate-900"
|
||||
title={session.title}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
@@ -185,79 +207,89 @@ export function AgentHistoryPanel({
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<StatusDot tone="info" activity="live" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
|
||||
<Loader2
|
||||
size={13}
|
||||
className="animate-spin text-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
当前
|
||||
</span>
|
||||
<StatusBadge tone="info">当前</StatusBadge>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={deleting}
|
||||
loadingLabel="正在删除对话"
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
size="icon-sm"
|
||||
loading={saving}
|
||||
loadingLabel="正在保存对话主题"
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
@@ -265,7 +297,7 @@ export function AgentHistoryPanel({
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -273,7 +305,14 @@ export function AgentHistoryPanel({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<motion.div
|
||||
key="history-empty"
|
||||
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentMessageDetails } from "./agent-message-details";
|
||||
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgentMessageDetails question answers", () => {
|
||||
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析范围",
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{ label: "雨污混接分析", description: "检查混接问题" },
|
||||
{ label: "水质异常分析", description: "检查水质指标" }
|
||||
]
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
|
||||
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["检查高峰时段压力"]]
|
||||
);
|
||||
});
|
||||
|
||||
it("combines selected options with a custom answer for a multiple-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析内容",
|
||||
question: "需要包含哪些内容?",
|
||||
options: [
|
||||
{ label: "压力", description: "分析节点压力" },
|
||||
{ label: "流量", description: "分析管段流量" }
|
||||
],
|
||||
multiple: true,
|
||||
custom: true
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["压力", "补充水龄"]]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderQuestion(
|
||||
question: AgentQuestionInfo,
|
||||
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
|
||||
) {
|
||||
render(
|
||||
<AgentMessageDetails
|
||||
message={{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "需要补充信息",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [question],
|
||||
createdAt: 100,
|
||||
status: "pending"
|
||||
}
|
||||
]
|
||||
}}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,16 @@ import {
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusActivity,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import {
|
||||
agentEase,
|
||||
agentLayoutTransition,
|
||||
@@ -37,63 +44,64 @@ type QuestionStatus = AgentQuestionRequest["status"];
|
||||
|
||||
const STREAMING_PROGRESS_LIMIT = 3;
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
type AgentStatusMeta = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
activity?: StatusActivity;
|
||||
};
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
|
||||
running: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
tone: "info",
|
||||
activity: "live"
|
||||
},
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-100"
|
||||
label: "已完成",
|
||||
tone: "success"
|
||||
},
|
||||
error: {
|
||||
label: "失败",
|
||||
className: "bg-red-100 text-red-700",
|
||||
dotClassName: "border-red-500 bg-red-100"
|
||||
tone: "danger"
|
||||
}
|
||||
};
|
||||
|
||||
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-500"
|
||||
tone: "success"
|
||||
},
|
||||
in_progress: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
tone: "info",
|
||||
activity: "live"
|
||||
},
|
||||
pending: {
|
||||
label: "待办",
|
||||
className: "bg-slate-100 text-slate-600",
|
||||
dotClassName: "border-slate-300 bg-white"
|
||||
tone: "neutral"
|
||||
},
|
||||
cancelled: {
|
||||
label: "取消",
|
||||
className: "bg-slate-200 text-slate-500",
|
||||
dotClassName: "border-slate-300 bg-slate-200"
|
||||
tone: "neutral"
|
||||
}
|
||||
};
|
||||
|
||||
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
|
||||
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
|
||||
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
|
||||
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
|
||||
const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
|
||||
approved_always: { label: "已始终允许", tone: "success" },
|
||||
approved_once: { label: "已允许一次", tone: "success" },
|
||||
rejected: { label: "已拒绝", tone: "danger" },
|
||||
aborted: { label: "已中止", tone: "neutral" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||
pending: { label: "待确认", tone: "warning" }
|
||||
};
|
||||
|
||||
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
|
||||
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
|
||||
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
|
||||
answered: { label: "已回答", tone: "success" },
|
||||
rejected: { label: "已跳过", tone: "neutral" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
submitting: { label: "提交中", tone: "info", activity: "live" },
|
||||
pending: { label: "待回答", tone: "info" }
|
||||
};
|
||||
|
||||
export function AgentMessageDetails({
|
||||
@@ -198,6 +206,7 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
|
||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const listId = useId();
|
||||
const compact = running && !expanded;
|
||||
const visibleProgress = expanded
|
||||
? progress
|
||||
@@ -207,33 +216,47 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<span>执行进度</span>
|
||||
<span className="flex-1" />
|
||||
<span className="shrink-0 font-normal text-slate-400">
|
||||
{expanded
|
||||
? `全部 ${progress.length} 条`
|
||||
: running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: `共 ${progress.length} 条`}
|
||||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
|
||||
{running ? (
|
||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||
) : null}
|
||||
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||||
<span className="font-mono tabular-nums text-slate-600">
|
||||
{expanded || !running
|
||||
? progress.length
|
||||
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||||
<span className="px-0.5 text-slate-300">/</span>
|
||||
{progress.length}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
|
||||
expanded && "text-blue-500"
|
||||
)}
|
||||
aria-controls={listId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "收起执行进度" : "展开执行进度"}
|
||||
data-state={expanded ? "open" : "closed"}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
id={listId}
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
@@ -254,26 +277,11 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: agentEase }}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
||||
progressStatusMeta[item.status].dotClassName
|
||||
)}
|
||||
animate={
|
||||
item.status === "running"
|
||||
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
||||
: item.status === "error"
|
||||
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
||||
: { opacity: 1, scale: 1 }
|
||||
}
|
||||
transition={
|
||||
item.status === "running"
|
||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
||||
: item.status === "error"
|
||||
? { duration: 0.28, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
<StatusDot
|
||||
tone={progressStatusMeta[item.status].tone}
|
||||
activity={progressStatusMeta[item.status].activity}
|
||||
size="md"
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
@@ -302,21 +310,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
key={item.status}
|
||||
data-progress-status={item.status}
|
||||
className={cn(
|
||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
||||
progressStatusMeta[item.status].className
|
||||
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||
item.status === "running"
|
||||
? "text-blue-600"
|
||||
: [
|
||||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||
"text-[var(--status-foreground)]"
|
||||
]
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
item.status === "error"
|
||||
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||
: { opacity: 1, x: 0 }
|
||||
}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={
|
||||
item.status === "error"
|
||||
? { duration: 0.24, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
transition={{ duration: 0.14 }}
|
||||
>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</motion.span>
|
||||
@@ -333,7 +338,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
|
||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="status-tone-success text-[var(--status-foreground)]" title="计划">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||
@@ -346,20 +351,24 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
||||
todoStatusMeta[todo.status].dotClassName
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} /> : null}
|
||||
</SemanticStatusIcon>
|
||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
||||
<StatusBadge
|
||||
tone={todoStatusMeta[todo.status].tone}
|
||||
activity={todoStatusMeta[todo.status].activity}
|
||||
>
|
||||
{todoStatusMeta[todo.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -376,7 +385,7 @@ function PermissionList({
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="status-tone-warning text-[var(--status-foreground)]" title="权限请求">
|
||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
@@ -424,18 +433,22 @@ function PermissionRequestCard({
|
||||
{target}
|
||||
</p>
|
||||
{permission.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||
<p className="status-tone-danger mt-1 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{permission.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||
<StatusBadge
|
||||
tone={permissionStatusMeta[permission.status].tone}
|
||||
activity={permissionStatusMeta[permission.status].activity}
|
||||
className="self-start"
|
||||
>
|
||||
{permissionStatusMeta[permission.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="permission-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
className="mt-2 flex flex-wrap items-center gap-2"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -445,7 +458,7 @@ function PermissionRequestCard({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("once")}
|
||||
>
|
||||
@@ -457,7 +470,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("always")}
|
||||
>
|
||||
@@ -469,7 +482,7 @@ function PermissionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
||||
className="status-tone-danger h-8 border-[var(--status-border)] bg-[var(--status-soft)] px-2 text-xs text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("reject")}
|
||||
>
|
||||
@@ -547,9 +560,12 @@ function QuestionRequestCard({
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
||||
<StatusBadge
|
||||
tone={questionStatusMeta[request.status].tone}
|
||||
activity={questionStatusMeta[request.status].activity}
|
||||
>
|
||||
{questionStatusMeta[request.status].label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -558,7 +574,7 @@ function QuestionRequestCard({
|
||||
key={`${request.requestId}-${questionIndex}`}
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
|
||||
disabled={!actionable || submitting}
|
||||
onChange={(nextValue) =>
|
||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||
@@ -568,7 +584,7 @@ function QuestionRequestCard({
|
||||
</div>
|
||||
|
||||
{request.error ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
|
||||
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
|
||||
) : null}
|
||||
{request.answers?.length ? (
|
||||
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||
@@ -580,7 +596,7 @@ function QuestionRequestCard({
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="question-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
className="mt-2 flex flex-wrap items-center gap-2"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -590,7 +606,7 @@ function QuestionRequestCard({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
@@ -601,7 +617,7 @@ function QuestionRequestCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
disabled={submitting || !onRejectQuestion}
|
||||
onClick={reject}
|
||||
>
|
||||
@@ -617,6 +633,7 @@ function QuestionRequestCard({
|
||||
|
||||
type QuestionDraftAnswer = {
|
||||
selected: string[];
|
||||
customSelected: boolean;
|
||||
custom: string;
|
||||
};
|
||||
|
||||
@@ -633,8 +650,11 @@ function QuestionInput({
|
||||
disabled: boolean;
|
||||
onChange: (value: QuestionDraftAnswer) => void;
|
||||
}) {
|
||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||
const showCustomInput = question.custom || question.options.length === 0;
|
||||
const inputId = useId();
|
||||
const name = `agent-question-${questionIndex}-${inputId}`;
|
||||
const customEnabled = question.custom !== false;
|
||||
const showCustomChoice = customEnabled && question.options.length > 0;
|
||||
const showCustomInput = customEnabled && value.customSelected;
|
||||
|
||||
const toggleOption = (label: string, checked: boolean) => {
|
||||
if (question.multiple) {
|
||||
@@ -646,21 +666,36 @@ function QuestionInput({
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [label] : []
|
||||
selected: checked ? [label] : [],
|
||||
customSelected: false
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCustom = (checked: boolean) => {
|
||||
onChange({
|
||||
...value,
|
||||
selected: question.multiple ? value.selected : [],
|
||||
customSelected: checked
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||
{question.options.length ? (
|
||||
{question.options.length || showCustomChoice ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option) => {
|
||||
const checked = value.selected.includes(option.label);
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||||
checked
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
@@ -679,12 +714,34 @@ function QuestionInput({
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{showCustomChoice ? (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||||
value.customSelected
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
className="h-3.5 w-3.5 accent-blue-600"
|
||||
checked={value.customSelected}
|
||||
disabled={disabled}
|
||||
onChange={(event) => toggleCustom(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="font-semibold text-slate-700">自定义回答</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
aria-label="自定义回答内容"
|
||||
placeholder="输入自定义回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||
@@ -695,16 +752,25 @@ function QuestionInput({
|
||||
}
|
||||
|
||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||
return request.questions.map((question, index) => ({
|
||||
selected: request.answers?.[index] ?? [],
|
||||
custom: ""
|
||||
}));
|
||||
return request.questions.map((question, index) => {
|
||||
const optionLabels = new Set(question.options.map((option) => option.label));
|
||||
const savedAnswers = request.answers?.[index] ?? [];
|
||||
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
|
||||
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
|
||||
const customEnabled = question.custom !== false;
|
||||
|
||||
return {
|
||||
selected,
|
||||
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
|
||||
custom: customAnswers.join(";")
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||
return draftAnswers.map((answer) => {
|
||||
const custom = answer.custom.trim();
|
||||
return custom ? [...answer.selected, custom] : answer.selected;
|
||||
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Crosshair,
|
||||
MapPinned,
|
||||
Radio,
|
||||
Route
|
||||
} from "lucide-react";
|
||||
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type {
|
||||
AgentChatMessage
|
||||
} from "../types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||
import { countPendingAgentInputs } from "../message-state";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
|
||||
export function AgentOperationalBrief({
|
||||
messages,
|
||||
@@ -26,52 +20,71 @@ export function AgentOperationalBrief({
|
||||
|
||||
return (
|
||||
<div className="agent-panel-control rounded-2xl p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
||||
<AgentBriefMetric
|
||||
icon={<Radio size={13} aria-hidden="true" />}
|
||||
label="证据"
|
||||
value={brief.evidence}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<Route size={13} aria-hidden="true" />}
|
||||
label="计划"
|
||||
value={brief.plan}
|
||||
/>
|
||||
<AgentBriefMetric
|
||||
icon={<ClipboardCheck size={13} aria-hidden="true" />}
|
||||
label="确认"
|
||||
value={brief.confirmation}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||
>
|
||||
<MapPinned size={14} aria-hidden="true" />
|
||||
预览影响
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
<Crosshair size={14} aria-hidden="true" />
|
||||
检查证据
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
function AgentBriefMetric({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
</dt>
|
||||
@@ -82,26 +95,31 @@ function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: stri
|
||||
);
|
||||
}
|
||||
|
||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
||||
function createOperationalBrief(
|
||||
messages: AgentChatMessage[],
|
||||
statusLabel: string,
|
||||
streaming: boolean
|
||||
) {
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user" && message.content.trim());
|
||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
||||
const latestProgress = progressItems.at(-1);
|
||||
const activeTodo =
|
||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||
const pendingConfirmations =
|
||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
||||
const pendingConfirmations = countPendingAgentInputs(messages);
|
||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const plan =
|
||||
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
||||
const stateLabel =
|
||||
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusTone: StatusTone =
|
||||
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
|
||||
|
||||
return {
|
||||
@@ -110,7 +128,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
|
||||
plan,
|
||||
confirmation,
|
||||
stateLabel,
|
||||
statusDotClassName,
|
||||
statusTone,
|
||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "l
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
@@ -282,7 +283,7 @@ function TrustedPanelShell({
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
||||
<StatusIcon tone="success" size="md">{icon}</StatusIcon>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
export type {
|
||||
AgentCommandPanelPresentation,
|
||||
AgentWorkspaceContextItem
|
||||
} from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
AgentApiClient,
|
||||
AgentApiClientOptions,
|
||||
AgentChatSessionSummary,
|
||||
AgentLoadedChatSession,
|
||||
AgentSessionStreamEvent
|
||||
@@ -55,6 +61,7 @@ export type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentConnectionStatus,
|
||||
AgentInteractionToolRef,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
describe("Agent message state", () => {
|
||||
it("counts pending and failed operator inputs from one shared rule", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
permissions: [
|
||||
{ status: "pending" },
|
||||
{ status: "approved_once" }
|
||||
],
|
||||
questions: [
|
||||
{ status: "error" },
|
||||
{ status: "answered" }
|
||||
]
|
||||
}
|
||||
] as AgentChatMessage[];
|
||||
|
||||
expect(countPendingAgentInputs(messages)).toBe(2);
|
||||
expect(hasPendingAgentInput(messages)).toBe(true);
|
||||
expect(hasPendingAgentInput([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
export function countPendingAgentInputs(messages: AgentChatMessage[]) {
|
||||
return messages.reduce((total, message) => {
|
||||
const pendingPermissions =
|
||||
message.permissions?.filter(
|
||||
(permission) => permission.status === "pending" || permission.status === "error"
|
||||
).length ?? 0;
|
||||
const pendingQuestions =
|
||||
message.questions?.filter(
|
||||
(question) => question.status === "pending" || question.status === "error"
|
||||
).length ?? 0;
|
||||
return total + pendingPermissions + pendingQuestions;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function hasPendingAgentInput(messages: AgentChatMessage[]) {
|
||||
return countPendingAgentInputs(messages) > 0;
|
||||
}
|
||||
@@ -94,6 +94,33 @@ describe("agent session state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an actionable question id when its tool placeholder arrives later", () => {
|
||||
const questions = upsertQuestion(undefined, {
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 100
|
||||
});
|
||||
const withPlaceholder = upsertQuestion(questions, {
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 120
|
||||
});
|
||||
const rejected = applyQuestionResponse(withPlaceholder, {
|
||||
request_id: "question-1",
|
||||
rejected: true
|
||||
});
|
||||
|
||||
expect(withPlaceholder).toHaveLength(1);
|
||||
expect(rejected?.[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
status: "rejected"
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes open assistant state after abort", () => {
|
||||
const message: AgentChatMessage = {
|
||||
id: "assistant-1",
|
||||
|
||||
@@ -183,11 +183,24 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
|
||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
const current = next[index];
|
||||
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
|
||||
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
|
||||
|
||||
next[index] =
|
||||
currentIsActionable && nextIsToolPlaceholder
|
||||
? {
|
||||
...current,
|
||||
sessionId: nextItem.sessionId,
|
||||
questions: nextItem.questions,
|
||||
tool: nextItem.tool ?? current.tool,
|
||||
createdAt: nextItem.createdAt
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
...nextItem,
|
||||
status: current.status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type AgentStreamRenderMessageState = {
|
||||
|
||||
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
|
||||
|
||||
export type AgentConnectionStatus = "connecting" | "online" | "offline";
|
||||
|
||||
export type AgentChatProgress = {
|
||||
id: string;
|
||||
phase: string;
|
||||
|
||||
@@ -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 (firstNumeric) setProperty(firstNumeric[0]);
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
selectStyleGroup(nextGroupId);
|
||||
const allowedProperties = new Set(
|
||||
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
|
||||
);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
|
||||
([name]) => allowedProperties.has(name)
|
||||
);
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
function applyLayerStyle() {
|
||||
let patch: LayerGroupStylePatch;
|
||||
if (geometry === "line") {
|
||||
patch = {
|
||||
@@ -103,15 +189,44 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
opacity: Number(draft.opacity),
|
||||
radiusMultiplier: Number(draft.radiusMultiplier),
|
||||
strokeMultiplier: Number(draft.strokeMultiplier),
|
||||
iconMultiplier: Number(draft.iconMultiplier)
|
||||
};
|
||||
}
|
||||
commands.applyLayerGroupStyle(groupId, patch);
|
||||
}
|
||||
|
||||
function applyLabels() {
|
||||
if (showValueLabels || showFeatureIds) {
|
||||
void commands.showValueLabel({
|
||||
sourceId: groupId,
|
||||
property,
|
||||
precision,
|
||||
unit,
|
||||
showValue: showValueLabels,
|
||||
showId: showFeatureIds,
|
||||
textSize: labelSize,
|
||||
textColor: labelColor
|
||||
});
|
||||
} else {
|
||||
commands.clearValueLabel(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlowCalculation() {
|
||||
commands.updatePipeFlowCalculation({
|
||||
pipeId: flowPipeId.trim(),
|
||||
velocity: Number(flowVelocity)
|
||||
});
|
||||
}
|
||||
|
||||
const flowSummary = controllerState.flowSummary;
|
||||
const validFlowCalculation =
|
||||
flowPipeId.trim().length > 0 && Number.isFinite(Number(flowVelocity));
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
|
||||
@@ -119,14 +234,23 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
|
||||
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label="关闭 Dev Panel"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
|
||||
<div
|
||||
role="alert"
|
||||
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -134,101 +258,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Field label="业务源">
|
||||
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as AvailableWaterNetworkSourceId)} className={inputClassName}>
|
||||
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(event) => {
|
||||
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
className={inputClassName}
|
||||
>
|
||||
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GROUP_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Feature ID">
|
||||
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
|
||||
<input
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到 Feature
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.target ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
|
||||
<PanelSection
|
||||
title="全网水流"
|
||||
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
|
||||
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
|
||||
{flowSummary.reverse} · 停止 {flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后自动显示全网 mock 流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
|
||||
<Field label="数值属性">
|
||||
{numericProperties.length ? (
|
||||
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
|
||||
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
|
||||
</select>
|
||||
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
|
||||
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
|
||||
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
|
||||
<PanelSection
|
||||
title="样式编辑"
|
||||
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
|
||||
>
|
||||
<Field label="业务图层组">
|
||||
<div className="relative">
|
||||
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
|
||||
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={groupId}
|
||||
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
|
||||
className={cn(inputClassName, "appearance-none pr-8")}
|
||||
>
|
||||
{STYLE_GROUP_IDS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{GROUP_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
|
||||
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<Field label="线型">
|
||||
<select
|
||||
value={draft.dash}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, dash: event.target.value }))
|
||||
}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="original">原始</option>
|
||||
<option value="solid">实线</option>
|
||||
<option value="dashed">虚线</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
|
||||
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
|
||||
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
|
||||
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
|
||||
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
|
||||
作用于当前图层所有可见 Feature,自动避让重叠。
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
|
||||
MVT 原生
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<Field label="数值属性">
|
||||
<select
|
||||
value={property}
|
||||
onChange={(event) => setProperty(event.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
{LABEL_PRESETS[groupId].options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/70 p-3">
|
||||
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
重置全部
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
|
||||
function PanelSection({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
|
||||
function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
|
||||
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
|
||||
function ColorField({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-600">{value}</span>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
function RangeField({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={`${label} · ${value}`}>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-6 w-full accent-blue-600"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
export type MobileWorkbenchSheetSnap = "half" | "full";
|
||||
|
||||
export function getMobileWorkbenchSheetHeight(
|
||||
snap: MobileWorkbenchSheetSnap,
|
||||
viewportHeight: number
|
||||
) {
|
||||
if (snap === "half") {
|
||||
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio),
|
||||
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
describe("mobile workbench sheet heights", () => {
|
||||
it("keeps the two deterministic snap heights", () => {
|
||||
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
|
||||
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
|
||||
});
|
||||
|
||||
it("always leaves the configured map strip visible at full height", () => {
|
||||
const viewportHeight = 667;
|
||||
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
|
||||
|
||||
expect(viewportHeight - sheetHeight).toBe(68);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
getMobileWorkbenchSheetHeight,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./mobile-workbench-sheet-layout";
|
||||
|
||||
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
type MobileWorkbenchSheetProps = {
|
||||
label: string;
|
||||
snap: MobileWorkbenchSheetSnap;
|
||||
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
||||
|
||||
export function MobileWorkbenchSheet({
|
||||
label,
|
||||
snap,
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(
|
||||
typeof window === "undefined" ? 844 : window.innerHeight
|
||||
);
|
||||
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
y: event.clientY,
|
||||
height: restingHeight
|
||||
};
|
||||
draggedRef.current = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = start.y - event.clientY;
|
||||
if (Math.abs(delta) > 4) {
|
||||
draggedRef.current = true;
|
||||
}
|
||||
|
||||
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
|
||||
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
||||
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
||||
if (!pointerStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
||||
pointerStartRef.current = null;
|
||||
setDragHeight(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (!draggedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (draggedRef.current) {
|
||||
draggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = SNAP_ORDER.indexOf(snap);
|
||||
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
onSnapChange("half");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
onSnapChange("full");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
data-snap={snap}
|
||||
className={cn(
|
||||
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
||||
className
|
||||
)}
|
||||
style={{ height: dragHeight ?? restingHeight }}
|
||||
>
|
||||
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdjacentSnap(
|
||||
current: MobileWorkbenchSheetSnap,
|
||||
direction: -1 | 1
|
||||
): MobileWorkbenchSheetSnap {
|
||||
const currentIndex = SNAP_ORDER.indexOf(current);
|
||||
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
||||
return SNAP_ORDER[nextIndex] ?? current;
|
||||
}
|
||||
|
||||
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
||||
return SNAP_ORDER.reduce((closest, candidate) => {
|
||||
const candidateDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
||||
);
|
||||
const closestDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
||||
);
|
||||
return candidateDistance < closestDistance ? candidate : closest;
|
||||
}, "half");
|
||||
}
|
||||
|
||||
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
||||
if (snap === "half") {
|
||||
return "半屏档";
|
||||
}
|
||||
|
||||
return "全屏档";
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
getRunningWorkflowDefinition,
|
||||
@@ -37,25 +42,26 @@ import {
|
||||
formatDuration,
|
||||
formatTime,
|
||||
formatTimeRange,
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getRiskLabel,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStagePillClassName,
|
||||
statusClassNames,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
statusLabels
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||
|
||||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
||||
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||
|
||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
attention: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
critical: "border-red-200 bg-red-50 text-red-700"
|
||||
};
|
||||
const riskTones = {
|
||||
normal: "success",
|
||||
attention: "warning",
|
||||
critical: "danger"
|
||||
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
||||
|
||||
type IncidentOperation = {
|
||||
target: string;
|
||||
@@ -86,6 +92,7 @@ type ExecutionStep = {
|
||||
title: string;
|
||||
description: string;
|
||||
status: ExecutionStepStatus;
|
||||
statusLabel?: string;
|
||||
meta?: ExecutionStepMeta[];
|
||||
};
|
||||
|
||||
@@ -105,15 +112,21 @@ export function StatusPill({
|
||||
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
STATUS_PILL_CLASS_NAME,
|
||||
isWorkOrder ? getWorkOrderStagePillClassName(currentStage?.id) : statusClassNames[condition.status],
|
||||
className
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageTone(currentStage?.id)
|
||||
: getStatusTone(condition.status)
|
||||
}
|
||||
activity={
|
||||
isWorkOrder
|
||||
? getWorkOrderStageActivity(currentStage?.id)
|
||||
: getStatusActivity(condition.status)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +146,7 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="min-h-0"
|
||||
className="relative h-full min-h-0"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
@@ -198,7 +211,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={ShieldAlert}
|
||||
title="异常判断"
|
||||
iconClassName="text-red-600"
|
||||
iconClassName="status-tone-danger text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -221,7 +234,7 @@ function ConditionRecordDetailPanel({
|
||||
<DecisionSummaryBlock
|
||||
icon={Lightbulb}
|
||||
title="关注说明"
|
||||
iconClassName="text-orange-600"
|
||||
iconClassName="status-tone-warning text-[var(--status-foreground)]"
|
||||
summary={insight.summary}
|
||||
notes={insight.notes}
|
||||
/>
|
||||
@@ -274,7 +287,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
|
||||
icon={CalendarClock}
|
||||
title={currentStage ? `当前阶段:${currentStage.title}` : "工单流转"}
|
||||
value={condition.recommendation ?? "按派发、执行、复令三阶段跟踪现场处置闭环。"}
|
||||
actionLabel={currentStage?.id === "reply" ? "等待复令" : "跟踪工单"}
|
||||
actionLabel={currentStage?.id === "reply" ? "待复令" : "跟踪工单"}
|
||||
actionDisabled
|
||||
actionTitle="工单流程由调度和现场班组流转"
|
||||
/>
|
||||
@@ -331,8 +344,18 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
NEUTRAL_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -343,9 +366,7 @@ type DetailShellProps = {
|
||||
|
||||
function DetailShell({ children }: DetailShellProps) {
|
||||
return (
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,7 +541,9 @@ function IncidentWorkOrderPanel({
|
||||
className={cn(
|
||||
"mt-1 flex min-h-9 w-full items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors",
|
||||
"rounded-xl",
|
||||
submitted ? "cursor-not-allowed bg-green-50 text-green-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
||||
submitted
|
||||
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
|
||||
@@ -543,9 +566,9 @@ function IncidentOperationList({
|
||||
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionEyebrow icon={Wrench} label={operation.action} />
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", submitted ? "border-green-100 bg-green-50 text-green-700" : "border-slate-200 bg-white text-slate-500")}>
|
||||
<StatusBadge tone={submitted ? "success" : "neutral"}>
|
||||
{submitted ? "已通知" : operation.elementType}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<dl className="mt-1.5 grid gap-1 text-xs leading-5">
|
||||
<div className="grid grid-cols-[56px_1fr] gap-2">
|
||||
@@ -645,10 +668,9 @@ function ConditionStatusReport({
|
||||
|
||||
function LifecyclePill() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-green-100 bg-green-50 px-2 py-0.5 text-xs font-semibold leading-4 text-green-700">
|
||||
<CheckCircle2 size={12} aria-hidden="true" />
|
||||
<span>完成</span>
|
||||
</span>
|
||||
<StatusBadge tone="success" icon={<CheckCircle2 size={12} />}>
|
||||
完成
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -657,23 +679,16 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
|
||||
const label = getRiskLabel(riskLevel);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold leading-4",
|
||||
getRiskIndicatorClassName(riskLevel)
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={riskTones[riskLevel]}
|
||||
icon={<Icon size={12} />}
|
||||
aria-label={`风险等级:${label}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function getRiskIndicatorClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
return riskIndicatorClassNames[riskLevel];
|
||||
}
|
||||
|
||||
function ReportMetaTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={REPORT_META_TILE_CLASS_NAME}>
|
||||
@@ -709,7 +724,7 @@ function ReportSection({
|
||||
|
||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
return (
|
||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||
@@ -720,9 +735,9 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getRiskIndicatorClassName(kpi.status))}>
|
||||
<StatusBadge tone={riskTones[kpi.status]}>
|
||||
{getRiskLabel(kpi.status)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
|
||||
{kpi.threshold || kpi.baseline ? (
|
||||
@@ -735,18 +750,6 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
if (riskLevel === "critical") {
|
||||
return "border-red-200/80";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "border-orange-200/80";
|
||||
}
|
||||
|
||||
return "border-slate-200/70";
|
||||
}
|
||||
|
||||
function DecisionActionStrip({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -806,64 +809,36 @@ function SectionEyebrow({
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskExecutionBadgeClassName(tone: ExecutionBadgeTone) {
|
||||
function getTaskExecutionBadgeTone(tone: ExecutionBadgeTone): StatusTone {
|
||||
if (tone === "completed") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (tone === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
if (tone === "active") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
return "info";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||||
function getTaskExecutionStepTone(status: ExecutionStepStatus): StatusTone {
|
||||
if (status === "done") {
|
||||
return "bg-green-500 text-white";
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "bg-blue-600 text-white";
|
||||
return "info";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "bg-orange-500 text-white";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "bg-slate-100 text-slate-400";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||||
if (status === "current") {
|
||||
return "surface-reading border-blue-100";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "surface-reading border-orange-100";
|
||||
}
|
||||
|
||||
return "surface-reading border-slate-200/70";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||||
if (status === "done") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
if (status === "current") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-slate-200 bg-white text-slate-500";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusLabel(status: ExecutionStepStatus) {
|
||||
@@ -931,7 +906,6 @@ function RunningWorkflowPanel({
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={Loader2}
|
||||
title={workflow.title}
|
||||
badge="执行中"
|
||||
badgeTone="active"
|
||||
@@ -987,7 +961,7 @@ function TaskExecutionPanel({
|
||||
badgeTone = "pending",
|
||||
children
|
||||
}: {
|
||||
icon: typeof Bot;
|
||||
icon?: typeof Bot;
|
||||
title: string;
|
||||
badge: string;
|
||||
summary?: string;
|
||||
@@ -1000,10 +974,17 @@ function TaskExecutionPanel({
|
||||
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-semibold leading-4", getTaskExecutionBadgeClassName(badgeTone))}>
|
||||
{Icon ? (
|
||||
<SectionTitle icon={Icon} title={title} />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{title}</span>
|
||||
)}
|
||||
<StatusBadge
|
||||
tone={getTaskExecutionBadgeTone(badgeTone)}
|
||||
activity={badgeTone === "active" ? "live" : "static"}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{summary ? <p className="mt-2 text-xs leading-5 text-slate-700">{summary}</p> : null}
|
||||
{meta?.length ? (
|
||||
@@ -1042,29 +1023,32 @@ function TaskExecutionStepRow({
|
||||
{!isLast ? (
|
||||
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className={cn("relative z-10 grid h-7 w-7 place-items-center rounded-full ring-4 ring-white", getTaskExecutionStepIconClassName(step.status))}>
|
||||
<SemanticStatusIcon
|
||||
tone={getTaskExecutionStepTone(step.status)}
|
||||
activity={step.status === "current" ? "loading" : "static"}
|
||||
shape="circle"
|
||||
size="md"
|
||||
className="relative z-10 ring-4 ring-white"
|
||||
>
|
||||
{step.status === "done" ? (
|
||||
<CheckCircle2 size={14} aria-hidden="true" />
|
||||
<CheckCircle2 size={14} />
|
||||
) : step.status === "current" ? (
|
||||
<Loader2 size={14} aria-hidden="true" className="animate-spin" />
|
||||
<Loader2 size={14} />
|
||||
) : step.status === "confirmation" ? (
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<AlertTriangle size={14} />
|
||||
) : (
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<Clock3 size={14} />
|
||||
)}
|
||||
</span>
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border px-2.5 py-2",
|
||||
getTaskExecutionStepSurfaceClassName(step.status)
|
||||
)}
|
||||
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-slate-900">{step.title}</span>
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4", getTaskExecutionStepStatusClassName(step.status))}>
|
||||
{getTaskExecutionStepStatusLabel(step.status)}
|
||||
</span>
|
||||
<StatusBadge tone={getTaskExecutionStepTone(step.status)}>
|
||||
{step.statusLabel ?? getTaskExecutionStepStatusLabel(step.status)}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
{step.meta?.length ? (
|
||||
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]">
|
||||
@@ -1085,23 +1069,28 @@ function TaskExecutionStepRow({
|
||||
function WorkOrderLifecyclePanel({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||||
const duration = formatDuration(condition.durationMinutes, condition.status);
|
||||
const currentStage = getWorkOrderCurrentStage(condition);
|
||||
const steps = condition.stages.map((stage) => ({
|
||||
id: stage.id,
|
||||
title: stage.title,
|
||||
status: stage.status,
|
||||
description: stage.description,
|
||||
meta: [
|
||||
{ label: "责任人", value: stage.owner },
|
||||
{ label: "时间", value: stage.timeLabel }
|
||||
]
|
||||
}));
|
||||
const steps = condition.stages.map((stage) => {
|
||||
const awaitingReply = stage.id === "reply" && stage.status === "current";
|
||||
|
||||
return {
|
||||
id: stage.id,
|
||||
title: stage.title,
|
||||
status: awaitingReply ? ("confirmation" as const) : stage.status,
|
||||
statusLabel: awaitingReply ? "待复令" : undefined,
|
||||
description: stage.description,
|
||||
meta: [
|
||||
{ label: "责任人", value: stage.owner },
|
||||
{ label: "时间", value: stage.timeLabel }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<TaskExecutionPanel
|
||||
icon={ClipboardList}
|
||||
title="任务执行"
|
||||
badge={currentStage ? currentStage.title : condition.code}
|
||||
badgeTone={currentStage?.id === "reply" ? "completed" : currentStage?.id === "execution" ? "active" : "pending"}
|
||||
badgeTone={currentStage?.id === "reply" ? "confirmation" : currentStage?.id === "execution" ? "active" : "pending"}
|
||||
summary={condition.detail ?? condition.summary}
|
||||
meta={[
|
||||
{ label: "来源", value: condition.source },
|
||||
@@ -1152,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="line-clamp-1">{item}</span>
|
||||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
||||
<StatusBadge tone="info" activity="loading" className="mt-0.5">
|
||||
采集中
|
||||
</StatusBadge>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStatusActivity,
|
||||
getStatusTone,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
describe("scheduled condition status presentation", () => {
|
||||
it("maps lifecycle states to the shared semantic language", () => {
|
||||
expect({
|
||||
pending: getStatusTone("pending"),
|
||||
running: getStatusTone("running"),
|
||||
awaitingReply: getStatusTone("awaiting_reply"),
|
||||
completed: getStatusTone("completed"),
|
||||
warning: getStatusTone("warning"),
|
||||
error: getStatusTone("error")
|
||||
}).toEqual({
|
||||
pending: "neutral",
|
||||
running: "info",
|
||||
awaitingReply: "warning",
|
||||
completed: "success",
|
||||
warning: "warning",
|
||||
error: "danger"
|
||||
});
|
||||
expect(getStatusActivity("running")).toBe("loading");
|
||||
expect(getStatusActivity("awaiting_reply")).toBe("static");
|
||||
});
|
||||
|
||||
it("keeps work-order dispatch, execution, and reply visually distinct", () => {
|
||||
expect({
|
||||
dispatch: getWorkOrderStageTone("dispatch"),
|
||||
execution: getWorkOrderStageTone("execution"),
|
||||
reply: getWorkOrderStageTone("reply")
|
||||
}).toEqual({
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
});
|
||||
expect(getWorkOrderStageActivity("execution")).toBe("loading");
|
||||
});
|
||||
});
|
||||
@@ -5,51 +5,75 @@ import {
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionItemStatus,
|
||||
ScheduledWorkOrderItem,
|
||||
ScheduledWorkOrderStage
|
||||
} from "../types";
|
||||
|
||||
export const CONDITION_FILTER_ALL = "__all__";
|
||||
|
||||
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
awaiting_reply: "待复令",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
error: "border-red-100 bg-red-50 text-red-700",
|
||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
};
|
||||
const statusTones = {
|
||||
completed: "success",
|
||||
running: "info",
|
||||
awaiting_reply: "warning",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
pending: "neutral"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusTone>;
|
||||
|
||||
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
||||
const statusActivities = {
|
||||
completed: "static",
|
||||
running: "loading",
|
||||
awaiting_reply: "static",
|
||||
warning: "static",
|
||||
error: "static",
|
||||
pending: "static"
|
||||
} satisfies Record<ScheduledConditionItemStatus, StatusActivity>;
|
||||
|
||||
const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
running: Loader2,
|
||||
awaiting_reply: Clock3,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
warning: "bg-orange-50 text-orange-700",
|
||||
error: "bg-red-50 text-red-700",
|
||||
pending: "bg-slate-100 text-slate-600"
|
||||
};
|
||||
const workOrderStageTones = {
|
||||
dispatch: "neutral",
|
||||
execution: "info",
|
||||
reply: "warning"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusTone>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
||||
const workOrderStageActivities = {
|
||||
dispatch: "static",
|
||||
execution: "loading",
|
||||
reply: "static"
|
||||
} satisfies Record<ScheduledWorkOrderStage["id"], StatusActivity>;
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionItemStatus[] = [
|
||||
"running",
|
||||
"awaiting_reply",
|
||||
"warning",
|
||||
"error",
|
||||
"completed",
|
||||
"pending"
|
||||
];
|
||||
|
||||
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionItemStatus;
|
||||
|
||||
export type ConditionTaskFilterOption = {
|
||||
value: ConditionTaskFilterValue;
|
||||
@@ -58,7 +82,7 @@ export type ConditionTaskFilterOption = {
|
||||
};
|
||||
|
||||
export type ConditionStatusFilterOption = {
|
||||
value: ScheduledConditionStatus;
|
||||
value: ScheduledConditionItemStatus;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
@@ -88,7 +112,7 @@ export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): C
|
||||
}
|
||||
|
||||
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
||||
const statusCounts = new Map<ScheduledConditionItemStatus, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||
@@ -121,7 +145,7 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
|
||||
if (status === "running") {
|
||||
return "执行中";
|
||||
}
|
||||
@@ -154,22 +178,22 @@ export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
export function getWorkOrderStageTone(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageTones[stageId] : "neutral";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
||||
export function getWorkOrderStageActivity(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
return stageId ? workOrderStageActivities[stageId] : "static";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionItemStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
||||
return statusIconClassNames[status];
|
||||
export function getStatusTone(status: ScheduledConditionItemStatus) {
|
||||
return statusTones[status];
|
||||
}
|
||||
|
||||
export function getStatusActivity(status: ScheduledConditionItemStatus) {
|
||||
return statusActivities[status];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
X,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
|
||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||
import {
|
||||
CONDITION_FILTER_ALL,
|
||||
@@ -27,8 +29,12 @@ import {
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusActivity,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
getStatusTone,
|
||||
getWorkOrderCurrentStage,
|
||||
getWorkOrderStageActivity,
|
||||
getWorkOrderStageTone,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
@@ -36,10 +42,18 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
|
||||
"surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
"surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
presentation?: ScheduledConditionFeedPresentation;
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
@@ -50,6 +64,7 @@ type ScheduledConditionFeedProps = {
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onClose?: () => void;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -60,6 +75,7 @@ type ScheduledConditionFeedFocusRequest = {
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
presentation = "desktop-dock",
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
@@ -70,13 +86,21 @@ export function ScheduledConditionFeed({
|
||||
onFocusRequestHandled,
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onClose,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
@@ -89,10 +113,16 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
() =>
|
||||
conditions.filter(
|
||||
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
|
||||
),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const statusFilterOptions = useMemo(
|
||||
() => createStatusFilterOptions(taskFilteredConditions),
|
||||
[taskFilteredConditions]
|
||||
);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
@@ -101,7 +131,10 @@ export function ScheduledConditionFeed({
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(filteredConditions),
|
||||
[filteredConditions]
|
||||
);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
@@ -170,7 +203,9 @@ export function ScheduledConditionFeed({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
const targetCondition = allTimelineConditions.find(
|
||||
(condition) => condition.id === focusConditionId
|
||||
);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
@@ -180,11 +215,20 @@ export function ScheduledConditionFeed({
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
}, [
|
||||
allTimelineConditions,
|
||||
focusConditionId,
|
||||
focusRequestId,
|
||||
onFocusRequestHandled,
|
||||
updateExpanded,
|
||||
updateSelectedConditionId
|
||||
]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
@@ -226,7 +270,8 @@ export function ScheduledConditionFeed({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
message:
|
||||
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -240,15 +285,39 @@ export function ScheduledConditionFeed({
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -271,18 +340,39 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
{mobileSheet && onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭工况任务"
|
||||
title="关闭工况任务"
|
||||
onClick={onClose}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -295,9 +385,15 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
presentation={presentation}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
@@ -305,7 +401,7 @@ export function ScheduledConditionFeed({
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
@@ -325,6 +421,7 @@ export function ScheduledConditionFeed({
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
presentation: ScheduledConditionFeedPresentation;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
@@ -344,6 +441,7 @@ type ConditionTimelinePanelProps = {
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
presentation,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
@@ -365,9 +463,14 @@ function ConditionTimelinePanel({
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
data-testid="scheduled-condition-timeline"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -385,10 +488,15 @@ function ConditionTimelinePanel({
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
{(expanded || presentation === "mobile-sheet") && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionGroupHeader
|
||||
icon={ClipboardList}
|
||||
title="预约任务"
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 max-h-[132px] overflow-y-auto pl-0.5 pr-2 pt-0.5">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
selectedConditionId={selectedConditionId}
|
||||
@@ -397,7 +505,11 @@ function ConditionTimelinePanel({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<ConditionGroupHeader
|
||||
icon={History}
|
||||
title="最近工况"
|
||||
count={loading ? 0 : recentConditions.length}
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
@@ -410,7 +522,11 @@ function ConditionTimelinePanel({
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
description={
|
||||
filterActive
|
||||
? "调整任务或状态筛选后查看其他工况记录。"
|
||||
: "新的工况任务完成后会出现在这里。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,7 +562,7 @@ function ConditionFilterBar({
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
@@ -477,7 +593,7 @@ function ConditionFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
@@ -519,10 +635,15 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
<span
|
||||
className="block truncate font-semibold text-slate-800"
|
||||
title={selectedOption?.label}
|
||||
>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">
|
||||
{selectedOption?.count ?? 0} 条
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
@@ -536,7 +657,10 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
sideOffset={6}
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
@@ -582,7 +706,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
@@ -605,10 +732,7 @@ function ConditionDetailSkeleton() {
|
||||
return (
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
className="relative min-h-[420px] overflow-hidden px-4 py-3"
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
@@ -626,7 +750,10 @@ function ConditionDetailSkeleton() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<div
|
||||
key={index}
|
||||
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
|
||||
>
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
@@ -640,8 +767,18 @@ function ConditionDetailSkeleton() {
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
<section
|
||||
className={cn(
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME,
|
||||
"scheduled-feed-detail-viewport absolute inset-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="scheduled-condition-detail-scroll"
|
||||
className="scheduled-feed-scroll absolute inset-x-0 inset-y-2 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -653,9 +790,19 @@ type ConditionGroupHeaderProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
function ConditionGroupHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
compact = false
|
||||
}: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
|
||||
compact ? "mb-1" : "mb-2"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
@@ -694,9 +841,21 @@ type ConditionTimelineRowProps = {
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
function ConditionTimelineRow({
|
||||
condition,
|
||||
selected,
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const Icon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
const workOrderStage = isWorkOrder ? getWorkOrderCurrentStage(condition)?.id : undefined;
|
||||
const tone = isWorkOrder
|
||||
? getWorkOrderStageTone(workOrderStage)
|
||||
: getStatusTone(condition.status);
|
||||
const activity = isWorkOrder
|
||||
? getWorkOrderStageActivity(workOrderStage)
|
||||
: getStatusActivity(condition.status);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -705,44 +864,48 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
selected
|
||||
? "surface-reading border-blue-100 text-blue-900"
|
||||
: "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
|
||||
{formatTime(condition.scheduledAt)}
|
||||
</span>
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
<SemanticStatusIcon
|
||||
tone={tone}
|
||||
activity={activity}
|
||||
shape="circle"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
||||
selected
|
||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
||||
: isWorkOrder
|
||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
||||
: getStatusIconClassName(condition.status)
|
||||
"relative z-10 ring-4 ring-white",
|
||||
selected && "group-focus-visible:ring-blue-100"
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
<Icon size={12} />
|
||||
</SemanticStatusIcon>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
selected ? "text-blue-900" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
<span className="block truncate text-xs font-semibold leading-4">
|
||||
{condition.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||
{condition.summary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { ComponentProps } from "react";
|
||||
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
|
||||
@@ -14,105 +8,52 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
mobileOpen: boolean;
|
||||
mobilePanelCollapsing: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
onCollapsePanel: () => void;
|
||||
onCloseMobilePanel: () => void;
|
||||
onExpandPanel: () => void;
|
||||
onOpenMobilePanel: () => void;
|
||||
onWidthCommit?: (width: number) => void;
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
onCollapsePanel,
|
||||
onCloseMobilePanel,
|
||||
onExpandPanel,
|
||||
onOpenMobilePanel
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth);
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
|
||||
useEffect(() => {
|
||||
onWidthCommit?.(committedWidth);
|
||||
}, [committedWidth, onWidthCommit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={resize.handleKeyDown}
|
||||
onPointerDown={resize.handlePointerDown}
|
||||
>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
||||
style={{ width: panelOpen ? committedWidth : 0 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute left-3 top-5">
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileOpen ? (
|
||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={mobilePanelCollapsing}
|
||||
onCollapse={onCloseMobilePanel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={onOpenMobilePanel}
|
||||
className={cn(
|
||||
"grid h-12 w-12 place-items-center text-violet-700",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
type StatusTone
|
||||
} from "@/shared/ui/status";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
headerControlIconClassName
|
||||
@@ -47,6 +54,12 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const scenarioStatusTones = {
|
||||
active: "info",
|
||||
draft: "neutral",
|
||||
review: "warning"
|
||||
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
@@ -112,9 +125,9 @@ export function ScenarioMenu({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
@@ -161,29 +174,38 @@ export function AlertMenu({
|
||||
className={cn(
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
||||
compact && "h-10 w-10 justify-center px-0"
|
||||
hasAlerts && !compact &&
|
||||
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
|
||||
compact && compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts &&
|
||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
||||
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||
{compact ? (
|
||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
|
||||
!hasAlerts && "border-transparent! bg-transparent!"
|
||||
)}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
||||
>
|
||||
{alerts.length}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -199,7 +221,7 @@ export function AlertMenu({
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
||||
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
@@ -207,20 +229,22 @@ export function AlertMenu({
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
||||
<StatusDot tone="danger" size="md" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
<CheckCircle2 size={16} />
|
||||
</SemanticStatusIcon>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||
</div>
|
||||
@@ -259,7 +283,8 @@ export function UserMenu({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -268,13 +293,18 @@ export function UserMenu({
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
className={cn(
|
||||
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||
headerControlButtonClassName,
|
||||
compactHeaderControlButtonClassName
|
||||
)}
|
||||
aria-label="打开用户菜单"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
@@ -292,7 +322,15 @@ export function UserMenu({
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!onLogout}
|
||||
onSelect={onLogout ? () => void onLogout() : undefined}
|
||||
className={cn(
|
||||
"px-2 py-2",
|
||||
!onLogout && "text-slate-400",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
@@ -316,15 +354,13 @@ function AlertQueueSummary({
|
||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
<SemanticStatusIcon
|
||||
tone={hasAlerts ? "danger" : "success"}
|
||||
size="md"
|
||||
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
|
||||
</SemanticStatusIcon>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
@@ -332,17 +368,12 @@ function AlertQueueSummary({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
||||
)}
|
||||
>
|
||||
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
||||
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
|
||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,9 +458,9 @@ function MenuHeader({
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
|
||||
</div>
|
||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -469,7 +500,7 @@ function MenuAction({
|
||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
||||
tone === "warning" && "status-icon status-tone-warning",
|
||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
export const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
cn(
|
||||
buttonInteractionVariants({ tone: "neutral" }),
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
|
||||
);
|
||||
|
||||
export const compactHeaderControlButtonClassName =
|
||||
"h-10 w-10 justify-center px-0";
|
||||
|
||||
export const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
@@ -29,6 +28,7 @@ export type WorkbenchTopBarProps = {
|
||||
alerts: WorkbenchAlert[];
|
||||
user: WorkbenchUser;
|
||||
conditionFeedVisible: boolean;
|
||||
timelineVisible: boolean;
|
||||
taskTickerAvailable: boolean;
|
||||
taskTickerVisible: boolean;
|
||||
devPanelEnabled: boolean;
|
||||
@@ -36,16 +36,19 @@ export type WorkbenchTopBarProps = {
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onToggleTimeline: () => void;
|
||||
onToggleTaskTicker: () => void;
|
||||
onToggleDevPanel: () => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
onCreateTestAnalysis?: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||
@@ -58,6 +61,7 @@ export function WorkbenchTopBar({
|
||||
alerts,
|
||||
user,
|
||||
conditionFeedVisible,
|
||||
timelineVisible,
|
||||
taskTickerAvailable,
|
||||
taskTickerVisible,
|
||||
devPanelEnabled,
|
||||
@@ -65,19 +69,23 @@ export function WorkbenchTopBar({
|
||||
onSelectScenario,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onToggleTimeline,
|
||||
onToggleTaskTicker,
|
||||
onToggleDevPanel,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport,
|
||||
onAnalyzeAlertsWithAgent,
|
||||
onCreateTestAnalysis,
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const activeScenario =
|
||||
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
@@ -85,11 +93,18 @@ export function WorkbenchTopBar({
|
||||
return (
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||
供水管网智能调度系统
|
||||
</h1>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,19 +147,47 @@ export function WorkbenchTopBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{devPanelEnabled ? <div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="lg:hidden">
|
||||
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||
</div>
|
||||
{onCreateTestAnalysis ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="生成 Agent 模拟分析成果"
|
||||
title="生成 Agent 模拟分析成果"
|
||||
data-selection-tone="soft"
|
||||
onClick={onCreateTestAnalysis}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>测试分析</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
data-selection-tone="soft"
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
@@ -165,6 +208,7 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
@@ -191,20 +235,12 @@ function TaskTickerToggle({
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -212,30 +248,18 @@ function TaskTickerToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
@@ -243,12 +267,49 @@ function ConditionFeedToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
function TimelineToggle({
|
||||
visible,
|
||||
compact = false,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
compact?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
||||
aria-pressed={visible}
|
||||
data-selection-tone="soft"
|
||||
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
||||
onClick={onToggle}
|
||||
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{!compact ? <span className={compactHeaderTextClassName}>时间轴</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,4 +28,37 @@ describe("createOperationalScheduledConditions", () => {
|
||||
expect(currentCondition?.status).toBe("running");
|
||||
expect(currentCondition?.durationMinutes).toBe(5);
|
||||
});
|
||||
|
||||
it("labels the active work-order stage as executing", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
|
||||
const executingWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some(
|
||||
(stage) => stage.id === "execution" && stage.status === "current"
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
executingWorkOrder?.kind === "work_order"
|
||||
? executingWorkOrder.stages.find((stage) => stage.id === "execution")?.title
|
||||
: undefined
|
||||
).toBe("执行中");
|
||||
});
|
||||
|
||||
it("moves completed field work into a waiting-for-reply state", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
|
||||
const awaitingReplyWorkOrder = conditions.find(
|
||||
(condition) =>
|
||||
condition.kind === "work_order" &&
|
||||
condition.stages.some((stage) => stage.id === "reply" && stage.status === "current")
|
||||
);
|
||||
|
||||
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
|
||||
expect(
|
||||
awaitingReplyWorkOrder?.kind === "work_order"
|
||||
? awaitingReplyWorkOrder.stages.find((stage) => stage.id === "reply")?.title
|
||||
: undefined
|
||||
).toBe("待复令");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
ScheduledConditionReport,
|
||||
ScheduledConditionRiskLevel,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionTaskId
|
||||
ScheduledConditionTaskId,
|
||||
ScheduledWorkOrderStatus
|
||||
} from "../types";
|
||||
|
||||
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
|
||||
@@ -16,6 +17,7 @@ const MODEL_NAME = "DrainFlow Realtime Simulator";
|
||||
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
|
||||
|
||||
type ConditionTaskId = ScheduledConditionTaskId;
|
||||
type WorkOrderPhase = "dispatch" | "execution" | "reply";
|
||||
|
||||
type ConditionTaskDefinition = {
|
||||
id: ConditionTaskId;
|
||||
@@ -332,6 +334,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
.map((workOrder) => {
|
||||
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
|
||||
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
|
||||
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
|
||||
|
||||
return {
|
||||
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
|
||||
@@ -340,7 +343,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
|
||||
title: workOrder.title,
|
||||
summary: workOrder.summary,
|
||||
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
|
||||
status: getWorkOrderStatus(phase),
|
||||
riskLevel: workOrder.riskLevel,
|
||||
updatedAt: now.getTime(),
|
||||
detail: workOrder.detail,
|
||||
@@ -352,7 +355,7 @@ function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): Sched
|
||||
assignee: workOrder.assignee,
|
||||
priority: workOrder.priority,
|
||||
replyWindowMinutes: workOrder.replyWindowMinutes,
|
||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
|
||||
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, phase),
|
||||
replyRequirements:
|
||||
currentMinutesOfDay > replyDeadlineMinute
|
||||
? []
|
||||
@@ -385,11 +388,38 @@ function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMin
|
||||
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
|
||||
}
|
||||
|
||||
function getWorkOrderPhase(
|
||||
workOrder: ManualWorkOrderDefinition,
|
||||
currentMinutesOfDay: number
|
||||
): WorkOrderPhase {
|
||||
if (currentMinutesOfDay < workOrder.startMinute) {
|
||||
return "dispatch";
|
||||
}
|
||||
|
||||
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
|
||||
return "execution";
|
||||
}
|
||||
|
||||
return "reply";
|
||||
}
|
||||
|
||||
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
|
||||
if (phase === "dispatch") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (phase === "execution") {
|
||||
return "running";
|
||||
}
|
||||
|
||||
return "awaiting_reply";
|
||||
}
|
||||
|
||||
function createWorkOrderStages(
|
||||
workOrder: ManualWorkOrderDefinition,
|
||||
now: Date,
|
||||
scheduledAtDate: Date,
|
||||
currentMinutesOfDay: number
|
||||
phase: WorkOrderPhase
|
||||
) {
|
||||
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
|
||||
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
|
||||
@@ -397,37 +427,32 @@ function createWorkOrderStages(
|
||||
now,
|
||||
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
|
||||
);
|
||||
const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "dispatch" as const,
|
||||
title: "派发",
|
||||
status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const),
|
||||
status: phase === "dispatch" ? ("current" as const) : ("done" as const),
|
||||
owner: workOrder.dispatcher,
|
||||
timeLabel: formatClock(dispatchAt),
|
||||
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
|
||||
},
|
||||
{
|
||||
id: "execution" as const,
|
||||
title: "执行",
|
||||
title: "执行中",
|
||||
status:
|
||||
currentMinutesOfDay >= executionEndMinute
|
||||
? ("done" as const)
|
||||
: currentMinutesOfDay >= workOrder.startMinute
|
||||
phase === "dispatch"
|
||||
? ("pending" as const)
|
||||
: phase === "execution"
|
||||
? ("current" as const)
|
||||
: ("pending" as const),
|
||||
: ("done" as const),
|
||||
owner: workOrder.assignee,
|
||||
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
|
||||
description: workOrder.summary
|
||||
},
|
||||
{
|
||||
id: "reply" as const,
|
||||
title: "复令",
|
||||
status:
|
||||
currentMinutesOfDay >= executionEndMinute
|
||||
? ("current" as const)
|
||||
: ("pending" as const),
|
||||
title: "待复令",
|
||||
status: phase === "reply" ? ("current" as const) : ("pending" as const),
|
||||
owner: `${workOrder.assignee} → 调度中心`,
|
||||
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
|
||||
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
|
||||
|
||||
@@ -9,27 +9,44 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchViewportLayout
|
||||
getAgentPanelDefaultWidth,
|
||||
getAgentPanelMaxWidth
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
export function useAgentPanelResize(viewportWidth: number) {
|
||||
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const resizeLeftRef = useRef(0);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const widthRef = useRef<number | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
const [committedWidth, setCommittedWidth] = useState<number | null>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth((currentWidth) =>
|
||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
|
||||
);
|
||||
}, [viewportWidth]);
|
||||
if (widthRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resize = useCallback((clientX: number) => {
|
||||
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
|
||||
}, []);
|
||||
const nextWidth = clampAgentPanelWidth(widthRef.current, viewportWidth, conditionExpanded);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
setCommittedWidth(nextWidth);
|
||||
}, [conditionExpanded, viewportWidth]);
|
||||
|
||||
const resize = useCallback(
|
||||
(clientX: number) => {
|
||||
const nextWidth = clampAgentPanelWidth(
|
||||
clientX - resizeLeftRef.current,
|
||||
window.innerWidth,
|
||||
conditionExpanded
|
||||
);
|
||||
widthRef.current = nextWidth;
|
||||
setWidth(nextWidth);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -48,6 +65,7 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
const stopResizing = () => {
|
||||
cleanupRef.current?.();
|
||||
setResizing(false);
|
||||
setCommittedWidth(widthRef.current);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
@@ -64,28 +82,35 @@ export function useAgentPanelResize(viewportWidth: number) {
|
||||
[resize]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getAgentPanelDefaultWidth(window.innerWidth, conditionExpanded);
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = getAgentPanelMaxWidth(window.innerWidth, conditionExpanded);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
}, []);
|
||||
event.preventDefault();
|
||||
const clampedWidth = clampAgentPanelWidth(nextWidth, window.innerWidth, conditionExpanded);
|
||||
widthRef.current = clampedWidth;
|
||||
setWidth(clampedWidth);
|
||||
setCommittedWidth(clampedWidth);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
return {
|
||||
committedWidth,
|
||||
handleKeyDown,
|
||||
handlePointerDown,
|
||||
panelRef,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
@@ -13,10 +14,12 @@ import {
|
||||
createAgentApiClient,
|
||||
fetchAgentBootstrap,
|
||||
fetchAgentSessions,
|
||||
hasPendingAgentInput,
|
||||
isUiEnvelopeAllowed,
|
||||
parseUiEnvelopePayload,
|
||||
type AgentApprovalMode,
|
||||
type AgentChatMessage,
|
||||
type AgentConnectionStatus,
|
||||
type AgentModelOption,
|
||||
type AgentPermissionReply,
|
||||
type AgentPermissionRequest,
|
||||
@@ -45,25 +48,28 @@ import {
|
||||
|
||||
const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
|
||||
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
||||
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
||||
export function useWorkbenchAgent({
|
||||
onUiEnvelope,
|
||||
onFrontendAction,
|
||||
getAccessToken
|
||||
}: UseWorkbenchAgentOptions) {
|
||||
const collapseTimerRef = useRef<number | null>(null);
|
||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
const clientRef = useRef(createAgentApiClient());
|
||||
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||
const registryRef = useRef<UIRegistry | null>(null);
|
||||
const frontendActionEnabledRef = useRef(false);
|
||||
const onFrontendActionRef = useRef(onFrontendAction);
|
||||
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
@@ -71,7 +77,8 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<AgentConnectionStatus>("connecting");
|
||||
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
@@ -140,6 +147,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
|
||||
const handleDataPart = useCallback(
|
||||
(part: AgentDataPart) => {
|
||||
setConnectionStatus("online");
|
||||
const eventSessionId = getDataString(part.data, "session_id");
|
||||
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
@@ -203,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||
headers: async (): Promise<Record<string, string>> => {
|
||||
const accessToken = await getAccessToken?.();
|
||||
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||
},
|
||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||
return {
|
||||
body: {
|
||||
@@ -218,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
};
|
||||
}
|
||||
}),
|
||||
[]
|
||||
[getAccessToken]
|
||||
);
|
||||
|
||||
const chat = useChat<AgentUiMessage>({
|
||||
@@ -226,7 +238,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||
onError: (error) => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setRuntimeAvailability("failed");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
@@ -237,6 +249,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
},
|
||||
onFinish: () => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setConnectionStatus("online");
|
||||
setStatusLabel("Agent 分析完成");
|
||||
}
|
||||
});
|
||||
@@ -303,7 +316,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
setModelOptions(backendConnection.models);
|
||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||
if (chatRef.current.status === "ready") {
|
||||
setRuntimeAvailability("connected");
|
||||
setConnectionStatus("online");
|
||||
setStatusLabel("准备就绪");
|
||||
}
|
||||
}, [backendConnection]);
|
||||
@@ -313,7 +326,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeAvailability("unavailable");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 后端不可用");
|
||||
}, [backendConnectionError]);
|
||||
|
||||
@@ -371,10 +384,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
const personaState = useMemo(
|
||||
() => deriveAgentPersonaState(messages, {
|
||||
chatStatus: chat.status,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
connectionStatus
|
||||
}),
|
||||
[chat.status, messages, runtimeAvailability, statusLabel]
|
||||
[chat.status, connectionStatus, messages]
|
||||
);
|
||||
|
||||
const refreshSessionHistory = useCallback(async () => {
|
||||
@@ -478,7 +490,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRuntimeAvailability("failed");
|
||||
setConnectionStatus("offline");
|
||||
setStatusLabel("Agent 会话流恢复失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
@@ -589,7 +601,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
clearSessionId();
|
||||
setSessionTitle("新对话");
|
||||
setStatusLabel(nextStatusLabel);
|
||||
setRuntimeAvailability("connected");
|
||||
setConnectionStatus("online");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
setStreamRenderState({});
|
||||
@@ -793,6 +805,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
modelOptions,
|
||||
openMobilePanel,
|
||||
closeMobilePanel,
|
||||
connectionStatus,
|
||||
panelCollapsing,
|
||||
panelOpen,
|
||||
personaState,
|
||||
@@ -824,23 +837,17 @@ function deriveAgentPersonaState(
|
||||
messages: AgentChatMessage[],
|
||||
{
|
||||
chatStatus,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
connectionStatus
|
||||
}: {
|
||||
chatStatus: string;
|
||||
runtimeAvailability: AgentRuntimeAvailability;
|
||||
statusLabel: string;
|
||||
connectionStatus: AgentConnectionStatus;
|
||||
}
|
||||
): PersonaState {
|
||||
if (
|
||||
runtimeAvailability === "unavailable" ||
|
||||
runtimeAvailability === "failed" ||
|
||||
/失败|不可用|错误|降级/.test(statusLabel)
|
||||
) {
|
||||
if (connectionStatus === "offline") {
|
||||
return "asleep";
|
||||
}
|
||||
|
||||
if (hasPendingOperatorInput(messages)) {
|
||||
if (hasPendingAgentInput(messages)) {
|
||||
return "listening";
|
||||
}
|
||||
|
||||
@@ -848,7 +855,7 @@ function deriveAgentPersonaState(
|
||||
chatStatus === "submitted" ||
|
||||
chatStatus === "streaming" ||
|
||||
hasRunningSessionWork(messages) ||
|
||||
runtimeAvailability === "connecting"
|
||||
connectionStatus === "connecting"
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
@@ -856,14 +863,6 @@ function deriveAgentPersonaState(
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
||||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
||||
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
||||
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
||||
|
||||
@@ -7,15 +7,31 @@ export function useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth
|
||||
}: {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
conditionPanelExpanded?: boolean;
|
||||
agentPanelWidth?: number;
|
||||
}) {
|
||||
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
|
||||
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
|
||||
const valuesRef = useRef({
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
valuesRef.current = {
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded,
|
||||
agentPanelWidth
|
||||
};
|
||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new WorkbenchMapController({
|
||||
@@ -24,14 +40,24 @@ export function useWorkbenchMapController({
|
||||
getPadding: () => {
|
||||
const map = mapRef.current;
|
||||
return map
|
||||
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
|
||||
? getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
valuesRef.current.leftPanelOpen,
|
||||
valuesRef.current.rightPanelOpen,
|
||||
valuesRef.current.conditionPanelExpanded,
|
||||
valuesRef.current.agentPanelWidth
|
||||
)
|
||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const controller = controllerRef.current;
|
||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
||||
const state = useSyncExternalStore(
|
||||
controller.subscribe,
|
||||
controller.getSnapshot,
|
||||
controller.getSnapshot
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
return { controller, state };
|
||||
}
|
||||
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
waterNetworkInteractionLayers
|
||||
} from "../map/layers";
|
||||
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||
import {
|
||||
VALUE_LABEL_LAYER_IDS,
|
||||
VALUE_LABEL_SOURCE_ID,
|
||||
createEmptyValueLabelCollection
|
||||
} from "../map/value-label";
|
||||
import { createValueLabelLayers } from "../map/value-label";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
@@ -47,8 +43,6 @@ declare global {
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
@@ -81,30 +75,25 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "mapbox-base",
|
||||
"mapbox-satellite": "mapbox-base",
|
||||
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])),
|
||||
...Object.fromEntries(
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
),
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
|
||||
}, [leftPanelOpen, rightPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
}, [impactVisible]);
|
||||
@@ -130,38 +119,31 @@ export function useWorkbenchMap({
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
const sources = createWaterNetworkSources();
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]));
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
||||
map.addSource(sourceId, sources[sourceId])
|
||||
);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
const activeScadaBusinessLayers = scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[0],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
map.addLayer({
|
||||
id: VALUE_LABEL_LAYER_IDS[1],
|
||||
type: "symbol",
|
||||
source: VALUE_LABEL_SOURCE_ID,
|
||||
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
|
||||
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
|
||||
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
|
||||
});
|
||||
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
|
||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers
|
||||
.filter((layer) => layer.type === "symbol")
|
||||
.forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
@@ -169,7 +151,11 @@ export function useWorkbenchMap({
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-icons",
|
||||
scadaRegistration.status === "ready" ? "online" : scadaRegistration.status === "degraded" ? "degraded" : "offline",
|
||||
scadaRegistration.status === "ready"
|
||||
? "online"
|
||||
: scadaRegistration.status === "degraded"
|
||||
? "degraded"
|
||||
: "offline",
|
||||
scadaRegistration.status === "ready"
|
||||
? "SCADA 图标已就绪。"
|
||||
: scadaRegistration.status === "degraded"
|
||||
@@ -180,11 +166,19 @@ export function useWorkbenchMap({
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
let resizeFrame: number | null = null;
|
||||
const resizeMap = () => map.resize();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = null;
|
||||
resizeMap();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
window.addEventListener("resize", resizeMap);
|
||||
window.setTimeout(resizeMap, 0);
|
||||
window.setTimeout(resizeMap, 300);
|
||||
window.setTimeout(resizeMap, 1000);
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||
@@ -199,7 +193,12 @@ export function useWorkbenchMap({
|
||||
if (!message.includes("AbortError")) {
|
||||
const sourceGroupId = getSourceGroupFromErrorEvent(event);
|
||||
if (sourceGroupId) {
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
sourceGroupId,
|
||||
"offline",
|
||||
getSourceErrorMessage(sourceGroupId, message)
|
||||
);
|
||||
} else {
|
||||
setMapError(message);
|
||||
}
|
||||
@@ -210,6 +209,10 @@ export function useWorkbenchMap({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeMap);
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
@@ -248,7 +251,11 @@ export function useWorkbenchMap({
|
||||
function updateStatusFromSourceEvent(
|
||||
event: MapSourceDataEvent,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
|
||||
setSourceStatuses: (
|
||||
updater: (
|
||||
current: Record<string, WorkbenchSourceStatus>
|
||||
) => Record<string, WorkbenchSourceStatus>
|
||||
) => void
|
||||
) {
|
||||
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
if (!sourceGroupId) {
|
||||
@@ -259,11 +266,20 @@ function updateStatusFromSourceEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
sourceGroupId,
|
||||
status,
|
||||
getSourceStatusMessage(sourceGroupId, status)
|
||||
);
|
||||
}
|
||||
|
||||
function updateSourceStatus(
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
|
||||
setSourceStatuses: (
|
||||
updater: (
|
||||
current: Record<string, WorkbenchSourceStatus>
|
||||
) => Record<string, WorkbenchSourceStatus>
|
||||
) => void,
|
||||
sourceGroupId: string,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
message: string
|
||||
@@ -305,15 +321,21 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
|
||||
|
||||
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
return status === "online"
|
||||
? "底图瓦片已恢复正常。"
|
||||
: "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return status === "online" ? "供水管网矢量瓦片已加载。" : "供水管网瓦片请求中断,当前视图可能不完整。";
|
||||
return status === "online"
|
||||
? "供水管网矢量瓦片已加载。"
|
||||
: "供水管网瓦片请求中断,当前视图可能不完整。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
return status === "online"
|
||||
? "业务标注和影响范围已加载。"
|
||||
: "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
}
|
||||
|
||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWorkbenchResponsiveLayout } from "./use-workbench-responsive-layout";
|
||||
|
||||
function mockViewport(width: number, largeScreen: boolean) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: width
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: largeScreen,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("workbench responsive layout", () => {
|
||||
it("coordinates the expanded condition feed with the desktop Agent panel", () => {
|
||||
mockViewport(1366, true);
|
||||
const collapseAgentPanel = vi.fn();
|
||||
const expandAgentPanel = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useWorkbenchResponsiveLayout({
|
||||
activeToolOpen: false,
|
||||
agentPanelOpen: true,
|
||||
devPanelOpen: false,
|
||||
collapseAgentPanel,
|
||||
expandAgentPanel,
|
||||
onClearActiveTool: vi.fn()
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isLargeScreen).toBe(true);
|
||||
expect(result.current.shouldShowConditionFeed).toBe(false);
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.shouldShowConditionFeed).toBe(true);
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||
expect(collapseAgentPanel).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(false));
|
||||
expect(result.current.conditionFeedExpanded).toBe(false);
|
||||
expect(expandAgentPanel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("toggles the condition sheet without leaking desktop panel state on mobile", () => {
|
||||
mockViewport(390, false);
|
||||
const onClearActiveTool = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useWorkbenchResponsiveLayout({
|
||||
activeToolOpen: true,
|
||||
agentPanelOpen: true,
|
||||
devPanelOpen: false,
|
||||
collapseAgentPanel: vi.fn(),
|
||||
expandAgentPanel: vi.fn(),
|
||||
onClearActiveTool
|
||||
})
|
||||
);
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.mobileSheet).toBe("condition");
|
||||
expect(result.current.mobileSheetSnap).toBe("half");
|
||||
expect(onClearActiveTool).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.mobileSheet).toBeNull();
|
||||
|
||||
act(() => result.current.openAgentPanelForViewport());
|
||||
expect(result.current.mobileSheet).toBe("agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
const CONDITION_FEED_EXIT_MS = 170;
|
||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
|
||||
|
||||
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
|
||||
|
||||
type UseWorkbenchResponsiveLayoutOptions = {
|
||||
activeToolOpen: boolean;
|
||||
agentPanelOpen: boolean;
|
||||
devPanelOpen: boolean;
|
||||
collapseAgentPanel: () => void;
|
||||
expandAgentPanel: () => void;
|
||||
onClearActiveTool: () => void;
|
||||
};
|
||||
|
||||
export function useWorkbenchResponsiveLayout({
|
||||
activeToolOpen,
|
||||
agentPanelOpen,
|
||||
devPanelOpen,
|
||||
collapseAgentPanel,
|
||||
expandAgentPanel,
|
||||
onClearActiveTool
|
||||
}: UseWorkbenchResponsiveLayoutOptions) {
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||||
WORKBENCH_LAYOUT.desktop.agentWidth
|
||||
);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(LARGE_SCREEN_QUERY);
|
||||
const handleViewportChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
if (mediaQuery.matches) {
|
||||
setMobileSheet(null);
|
||||
}
|
||||
};
|
||||
|
||||
handleViewportChange();
|
||||
setConditionFeedVisible(false);
|
||||
mediaQuery.addEventListener("change", handleViewportChange);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleViewportChange);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowConditionFeed) {
|
||||
setConditionFeedMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setConditionFeedMounted(false);
|
||||
}, CONDITION_FEED_EXIT_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [shouldShowConditionFeed]);
|
||||
|
||||
function closeMobileSheet() {
|
||||
setMobileSheet(null);
|
||||
}
|
||||
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
expandAgentPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setAgentCollapsedForCondition(agentPanelOpen);
|
||||
collapseAgentPanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
setConditionFeedExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
}, [
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
expandAgentPanel,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
onClearActiveTool();
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileSheet === "condition") {
|
||||
closeMobileSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheet("condition");
|
||||
}
|
||||
|
||||
function openConditionFeedForViewport() {
|
||||
setConditionFeedVisible(true);
|
||||
onClearActiveTool();
|
||||
if (!isLargeScreen) {
|
||||
setMobileSheet("condition");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
}
|
||||
|
||||
function openTimelineForViewport() {
|
||||
if (isLargeScreen) return;
|
||||
setMobileSheet("timeline");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
const rightPanelExpanded =
|
||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||
|
||||
return {
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
mobileSheet,
|
||||
mobileSheetSnap,
|
||||
openAgentPanelForViewport,
|
||||
openConditionFeedForViewport,
|
||||
openTimelineForViewport,
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
viewportWidth
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,33 @@ export type {
|
||||
WorkbenchMapControllerState,
|
||||
WorkbenchMapErrorCode
|
||||
} from "./map/workbench-map-controller";
|
||||
export type {
|
||||
PipeFlowCalculation,
|
||||
PipeFlowDirection,
|
||||
PipeFlowSummary
|
||||
} from "./map/flow-overlay";
|
||||
export type {
|
||||
LayerGroupId,
|
||||
LayerGroupStylePatch,
|
||||
LineLayerGroupStylePatch,
|
||||
PointLayerGroupStylePatch
|
||||
} from "./map/layer-group-style";
|
||||
export type {
|
||||
AnalysisArtifact,
|
||||
AnalysisArtifactAction,
|
||||
AnalysisBlock,
|
||||
ArtifactLifecycle,
|
||||
ArtifactMapRelation,
|
||||
ArtifactRequestedView,
|
||||
ArtifactType,
|
||||
ArtifactViewMode,
|
||||
WorkbenchSurfaceMode
|
||||
} from "./workspace/workspace-model";
|
||||
export type {
|
||||
OperationalEvent,
|
||||
OperationalEventImportance,
|
||||
OperationalEventLane,
|
||||
OperationalEventStatus,
|
||||
OperationalTimelineMode
|
||||
} from "./operational-timeline/operational-timeline-model";
|
||||
export type { WorkspaceContext, WorkspaceTimeRange } from "./workspace/workspace-context";
|
||||
|
||||
@@ -1,5 +1,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,77 @@ 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("does not reserve map width for the collapsed Agent launcher", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1440, {
|
||||
agentOpen: false,
|
||||
conditionOpen: false
|
||||
}).left
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 620,
|
||||
conditionOpen: true
|
||||
})
|
||||
).toEqual({ top: 72, right: 504, bottom: 32, left: 644 });
|
||||
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
agentOpen: true,
|
||||
agentWidth: 900,
|
||||
conditionOpen: false
|
||||
}).left
|
||||
).toBe(744);
|
||||
});
|
||||
|
||||
it("uses responsive defaults and preserves the map corridor at maximum width", () => {
|
||||
expect(getAgentPanelDefaultWidth(390)).toBe(500);
|
||||
expect(getAgentPanelDefaultWidth(1024)).toBe(484);
|
||||
expect(getAgentPanelDefaultWidth(1440)).toBe(500);
|
||||
expect(getAgentPanelDefaultWidth(1536)).toBe(540);
|
||||
expect(getAgentPanelMaxWidth(1024)).toBe(484);
|
||||
expect(clampAgentPanelWidth(620, 1024)).toBe(484);
|
||||
expect(getAgentPanelMaxWidth(1280)).toBe(516);
|
||||
expect(getAgentPanelMaxWidth(1440)).toBe(676);
|
||||
expect(getAgentPanelMaxWidth(1536)).toBe(720);
|
||||
expect(getAgentPanelMaxWidth(1920)).toBe(720);
|
||||
expect(getAgentPanelMaxWidth(1920, true)).toBe(628);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,33 +3,81 @@ export const WORKBENCH_LAYOUT = {
|
||||
desktopMinWidth: 1024,
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
maxAgentViewportRatio: 0.5,
|
||||
collapsedAgentWidth: 0,
|
||||
maxAgentWidth: 720,
|
||||
agentPanelLeftInset: 12,
|
||||
conditionPanelRightInset: 64,
|
||||
minimumAgentMapCorridor: 256,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 880,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
agentWidth: 540,
|
||||
conditionWidth: 432,
|
||||
conditionExpandedWidth: 960,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
},
|
||||
mobileSheet: {
|
||||
halfViewportRatio: 0.52,
|
||||
fullTopGap: 68
|
||||
}
|
||||
} 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`
|
||||
|
||||
@@ -2,18 +2,22 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { CalendarClock, Clock3 } from "lucide-react";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
type AgentUiResult
|
||||
type AgentUiResult,
|
||||
type AgentWorkspaceContextItem
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
@@ -27,11 +31,22 @@ import {
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
||||
import {
|
||||
createOperationalEvents,
|
||||
getOperationalEventSummary,
|
||||
OPERATIONAL_TIMELINE_LAYOUT,
|
||||
resolveTimelineScaleLayout,
|
||||
type OperationalEvent,
|
||||
type OperationalTimelineMode
|
||||
} from "./operational-timeline/operational-timeline-model";
|
||||
import {
|
||||
ToolbarPanel,
|
||||
type ExportViewPreset,
|
||||
@@ -39,11 +54,20 @@ import {
|
||||
type ToolbarToolId
|
||||
} from "./components/toolbar-panel";
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
WorkbenchMainFrame
|
||||
} from "./workspace/workbench-main-frame";
|
||||
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
|
||||
import {
|
||||
createInitialWorkspaceContext,
|
||||
reduceWorkspaceContext
|
||||
} from "./workspace/workspace-context";
|
||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
|
||||
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import {
|
||||
@@ -63,7 +87,6 @@ import {
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
MAP_LEGEND_ITEMS,
|
||||
WORKBENCH_LAYER_GROUPS,
|
||||
applyBaseLayerVisibility,
|
||||
createLayerControlItems,
|
||||
getWorkbenchLayerIds
|
||||
@@ -73,7 +96,8 @@ import type {
|
||||
DetailFeature,
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionRecord,
|
||||
WorkbenchAlert
|
||||
WorkbenchAlert,
|
||||
WorkbenchUser
|
||||
} from "./types";
|
||||
import {
|
||||
createScheduledConditionAlerts,
|
||||
@@ -83,19 +107,23 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
||||
|
||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||
|
||||
export function MapWorkbenchPage() {
|
||||
export function MapWorkbenchPage({
|
||||
user = WORKBENCH_USER,
|
||||
onLogout,
|
||||
getAccessToken
|
||||
}: {
|
||||
user?: WorkbenchUser;
|
||||
onLogout?: () => Promise<void>;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
}) {
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||||
@@ -128,59 +156,109 @@ export function MapWorkbenchPage() {
|
||||
setDetailFeature(feature);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
|
||||
handleChange();
|
||||
setConditionFeedVisible(window.innerWidth >= 1280);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
window.addEventListener("resize", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
window.removeEventListener("resize", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowConditionFeed) {
|
||||
setConditionFeedMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setConditionFeedMounted(false);
|
||||
}, 170);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [shouldShowConditionFeed]);
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
onFrontendAction: handleFrontendAction,
|
||||
getAccessToken
|
||||
});
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
agent.expandPanel();
|
||||
return;
|
||||
}
|
||||
const collapseAgentPanel = agent.collapsePanel;
|
||||
const expandAgentPanel = agent.expandPanel;
|
||||
const clearActiveTool = useCallback(() => {
|
||||
setActiveToolId(null);
|
||||
}, []);
|
||||
const {
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
mobileSheet,
|
||||
mobileSheetSnap,
|
||||
openAgentPanelForViewport,
|
||||
openConditionFeedForViewport,
|
||||
openTimelineForViewport,
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
viewportWidth
|
||||
} = useWorkbenchResponsiveLayout({
|
||||
activeToolOpen: activeToolId !== null,
|
||||
agentPanelOpen: agent.panelOpen,
|
||||
devPanelOpen,
|
||||
collapseAgentPanel,
|
||||
expandAgentPanel,
|
||||
onClearActiveTool: clearActiveTool
|
||||
});
|
||||
const currentAgentWidth = leftPanelOpen
|
||||
? agentPanelWidth
|
||||
: WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
|
||||
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
|
||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
|
||||
reduceWorkspaceContext,
|
||||
undefined,
|
||||
() => createInitialWorkspaceContext()
|
||||
);
|
||||
const operationalEvents = useMemo(
|
||||
() => createOperationalEvents(scheduledConditions),
|
||||
[scheduledConditions]
|
||||
);
|
||||
const operationalSummary = useMemo(
|
||||
() => getOperationalEventSummary(operationalEvents),
|
||||
[operationalEvents]
|
||||
);
|
||||
const timelineScaleLayout = resolveTimelineScaleLayout(
|
||||
businessWorkspaceWidth,
|
||||
timelineMode,
|
||||
timelineVisible
|
||||
);
|
||||
const timelineBottom = isLargeScreen
|
||||
? timelineScaleLayout.timelineBottom
|
||||
: 12;
|
||||
|
||||
agent.openMobilePanel();
|
||||
useEffect(() => {
|
||||
setTimelineMode((current) => {
|
||||
if (artifactWorkspace.surfaceMode === "focus") return "summary";
|
||||
return current === "summary" ? "compact" : current;
|
||||
});
|
||||
}, [artifactWorkspace.surfaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchWorkspaceContext({
|
||||
type: "sync-artifact",
|
||||
artifact: artifactWorkspace.activeArtifact
|
||||
});
|
||||
}, [artifactWorkspace.activeArtifact]);
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
openAgentPanelForViewport();
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
function openConditionWorkspaceForViewport() {
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleTimelineVisibility() {
|
||||
setTimelineVisible((current) => {
|
||||
if (current && mobileSheet === "timeline") closeMobileSheet();
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature: handleSelectFeature,
|
||||
selectedFeature: detailFeature
|
||||
@@ -188,8 +266,10 @@ export function MapWorkbenchPage() {
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen
|
||||
leftPanelOpen: false,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
|
||||
const activeAgentUiResults = useMemo(
|
||||
@@ -223,10 +303,13 @@ export function MapWorkbenchPage() {
|
||||
mode: activeMeasureModeId,
|
||||
unit: activeMeasureUnitId
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
!agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
!artifactActive
|
||||
&& mobileSheet === null
|
||||
&& !conditionFeedExpanded
|
||||
&& taskTickerVisible
|
||||
&& taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -279,6 +362,54 @@ export function MapWorkbenchPage() {
|
||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
||||
}, []);
|
||||
|
||||
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-operational-event",
|
||||
eventId: event.id,
|
||||
cursorTime: event.cursorTime
|
||||
});
|
||||
|
||||
const mapTarget = event.mapTargets[0];
|
||||
if (mapTarget) {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-spatial-feature",
|
||||
featureId: mapTarget.featureId,
|
||||
sourceId: mapTarget.sourceId
|
||||
});
|
||||
void mapController.locateAndHighlight(mapTarget).then(() => {
|
||||
if (mapController.getSnapshot().errorCode) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "已选择运行事件",
|
||||
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (event.conditionId) {
|
||||
setSelectedConditionId(event.conditionId);
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId: event.conditionId!,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
}));
|
||||
openConditionFeedForViewport();
|
||||
handleConditionExpandedChange(true);
|
||||
}
|
||||
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
|
||||
|
||||
const handleClearOperationalSelection = useCallback(() => {
|
||||
dispatchWorkspaceContext({ type: "clear-operational-event" });
|
||||
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||
mapController.clearHighlight();
|
||||
}, [mapController]);
|
||||
|
||||
const handleReturnOperationalNow = useCallback(() => {
|
||||
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
|
||||
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||
mapController.clearHighlight();
|
||||
}, [mapController]);
|
||||
|
||||
const toolbarItems = useMemo<MapToolbarItem[]>(
|
||||
() =>
|
||||
waterNetworkToolbarItems.map((item) => ({
|
||||
@@ -307,9 +438,7 @@ export function MapWorkbenchPage() {
|
||||
}
|
||||
|
||||
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||||
}
|
||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,11 +533,10 @@ export function MapWorkbenchPage() {
|
||||
setImpactVisible(true);
|
||||
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
||||
|
||||
if (mapRef.current && mapReady) {
|
||||
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
|
||||
if (mapRef.current?.getLayer(layerId)) {
|
||||
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
|
||||
}
|
||||
const map = mapRef.current;
|
||||
if (map && mapReady) {
|
||||
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
|
||||
map.setLayoutProperty(layerId, "visibility", "visible");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -450,7 +578,7 @@ export function MapWorkbenchPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
openAgentPanelForViewport();
|
||||
openAgentWorkspaceForViewport();
|
||||
|
||||
try {
|
||||
await agent.submitPrompt(
|
||||
@@ -487,8 +615,7 @@ export function MapWorkbenchPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedVisible(true);
|
||||
setActiveToolId(null);
|
||||
openConditionWorkspaceForViewport();
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
@@ -608,7 +735,13 @@ export function MapWorkbenchPage() {
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
false,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
),
|
||||
duration: trustedAction.durationMs ?? 500
|
||||
});
|
||||
showMapNotice({
|
||||
@@ -722,6 +855,35 @@ export function MapWorkbenchPage() {
|
||||
onShowShortcuts: handleShowShortcuts,
|
||||
onExportConfig: handleExportConfig
|
||||
};
|
||||
const selectedOperationalEvent = operationalEvents.find(
|
||||
(event) => event.id === workspaceContext.operational.selectedEventId
|
||||
) ?? null;
|
||||
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
|
||||
const items: AgentWorkspaceContextItem[] = [{
|
||||
id: "operational-time",
|
||||
label: "运行时点",
|
||||
value: formatContextClock(workspaceContext.operational.cursorTime),
|
||||
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
|
||||
}];
|
||||
|
||||
if (workspaceContext.spatial.featureId) {
|
||||
items.push({
|
||||
id: "spatial-feature",
|
||||
label: "地图要素",
|
||||
value: workspaceContext.spatial.featureId,
|
||||
tone: "info"
|
||||
});
|
||||
}
|
||||
if (artifactWorkspace.activeArtifact) {
|
||||
items.push({
|
||||
id: "analytical-artifact",
|
||||
label: "分析成果",
|
||||
value: artifactWorkspace.activeArtifact.title,
|
||||
tone: "info"
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
|
||||
const agentPanelProps: Omit<
|
||||
ComponentProps<typeof AgentCommandPanel>,
|
||||
"collapsing" | "onCollapse"
|
||||
@@ -732,6 +894,7 @@ export function MapWorkbenchPage() {
|
||||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||||
activeSessionId: agent.sessionId,
|
||||
statusLabel: agent.statusLabel,
|
||||
connectionStatus: agent.connectionStatus,
|
||||
streaming: agent.streaming,
|
||||
messages: agent.messages,
|
||||
streamRenderState: agent.streamRenderState,
|
||||
@@ -739,6 +902,7 @@ export function MapWorkbenchPage() {
|
||||
selectedModel: agent.selectedModel,
|
||||
approvalMode: agent.approvalMode,
|
||||
uiResults: activeAgentUiResults,
|
||||
workspaceContext: agentWorkspaceContext,
|
||||
onRefreshHistory: agent.refreshSessionHistory,
|
||||
onStartNewSession: handleStartNewAgentSession,
|
||||
onLoadHistorySession: handleLoadAgentHistorySession,
|
||||
@@ -757,91 +921,152 @@ export function MapWorkbenchPage() {
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
data-basemap-tone={basemapTone}
|
||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
||||
style={
|
||||
{
|
||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||
"--workbench-agent-current-width": `${currentAgentWidth}px`,
|
||||
"--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="AquaDispatch v1.0"
|
||||
scenarios={WORKBENCH_SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
conditionFeedVisible={shouldShowConditionFeed}
|
||||
user={user}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
timelineVisible={timelineVisible}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={() => {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
setActiveToolId(null);
|
||||
}}
|
||||
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onExportConfig={handleExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<WorkbenchMainFrame
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
|
||||
onOpenPendingArtifact={artifactWorkspace.openPreview}
|
||||
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
|
||||
onSetArtifactView={artifactWorkspace.setView}
|
||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
||||
timelineMode={timelineMode}
|
||||
timelineBottom={timelineBottom}
|
||||
timelineContent={(
|
||||
timelineVisible ? <>
|
||||
<div className="hidden lg:block">
|
||||
<OperationalTimeline
|
||||
date={workspaceContext.operational.date}
|
||||
cursorTime={workspaceContext.operational.cursorTime}
|
||||
events={operationalEvents}
|
||||
mode={timelineMode}
|
||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||
onModeChange={setTimelineMode}
|
||||
onSelectEvent={handleSelectOperationalEvent}
|
||||
onClearSelection={handleClearOperationalSelection}
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTimelineForViewport}
|
||||
className="acrylic-control flex h-12 w-full items-center gap-2 rounded-2xl border px-3 text-left text-xs text-slate-700 lg:hidden"
|
||||
>
|
||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||
<span className="font-semibold">运行时间轴</span>
|
||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
||||
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
|
||||
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
|
||||
<span className="ml-auto text-slate-500">查看全天</span>
|
||||
</button>
|
||||
</> : null
|
||||
)}
|
||||
mapContent={(
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid absolute inset-0"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
)}
|
||||
mapOverlay={(
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 flex flex-col items-end gap-2"
|
||||
style={{ bottom: timelineScaleLayout.scaleBottom }}
|
||||
>
|
||||
<div className="flex items-end gap-2 pr-2">
|
||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||
</div>
|
||||
<MapScaleLine
|
||||
className="hidden lg:inline-flex"
|
||||
mapRef={mapRef}
|
||||
mapReady={mapReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<WorkbenchAgentPanels
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
mobileOpen={agent.mobileOpen}
|
||||
mobilePanelCollapsing={agent.mobilePanelCollapsing}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onCloseMobilePanel={agent.closeMobilePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onOpenMobilePanel={agent.openMobilePanel}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
/>
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onExpandAgent={openAgentWorkspaceForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
<MapDevPanel
|
||||
commands={mapController}
|
||||
@@ -851,8 +1076,8 @@ export function MapWorkbenchPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel
|
||||
{...toolbarPanelProps}
|
||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||
@@ -860,18 +1085,11 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
||||
<div className="flex items-end gap-2 pr-2">
|
||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||
</div>
|
||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
key="agent-task-ticker-shell"
|
||||
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
@@ -890,16 +1108,90 @@ export function MapWorkbenchPage() {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
||||
{!agent.mobileOpen ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}>
|
||||
{mobileSheet === null ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={openAgentPanelForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||
</button>
|
||||
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="h-11 justify-center"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
|
||||
>
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mobileSheet ? (
|
||||
<MobileWorkbenchSheet
|
||||
label={
|
||||
mobileSheet === "agent"
|
||||
? "Agent 工作台抽屉"
|
||||
: mobileSheet === "timeline"
|
||||
? "运行时间轴抽屉"
|
||||
: "工况任务抽屉"
|
||||
}
|
||||
snap={mobileSheetSnap}
|
||||
onSnapChange={setMobileSheetSnap}
|
||||
onClose={closeMobileSheet}
|
||||
>
|
||||
{mobileSheet === "agent" ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
presentation="mobile-sheet"
|
||||
onCollapse={closeMobileSheet}
|
||||
/>
|
||||
) : mobileSheet === "timeline" ? (
|
||||
<OperationalTimeline
|
||||
mobile
|
||||
date={workspaceContext.operational.date}
|
||||
cursorTime={workspaceContext.operational.cursorTime}
|
||||
events={operationalEvents}
|
||||
mode="expanded"
|
||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||
onSelectEvent={handleSelectOperationalEvent}
|
||||
onClearSelection={handleClearOperationalSelection}
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
/>
|
||||
) : (
|
||||
<ScheduledConditionFeed
|
||||
presentation="mobile-sheet"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onClose={closeMobileSheet}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
)}
|
||||
</MobileWorkbenchSheet>
|
||||
) : null}
|
||||
|
||||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||||
|
||||
{!mapReady && !mapError ? (
|
||||
@@ -951,3 +1243,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
||||
|
||||
return "当前分辨率";
|
||||
}
|
||||
|
||||
function formatContextClock(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -89,7 +89,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -124,7 +124,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
@@ -19,12 +15,40 @@ function createMap(width = 1280, height = 720) {
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 524 }
|
||||
},
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 524 }
|
||||
}
|
||||
])(
|
||||
"covers agent=$agentOpen and condition=$conditionOpen",
|
||||
({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses a committed Agent width for camera-safe operations", () => {
|
||||
expect(getWorkbenchPadding(true, false, false, 620)).toEqual({
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 32,
|
||||
left: 540
|
||||
});
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
@@ -59,7 +83,7 @@ describe("fitNetworkBounds", () => {
|
||||
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
|
||||
|
||||
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.8105, 6);
|
||||
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
|
||||
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
|
||||
expect(options).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
@@ -14,24 +12,35 @@ export type NetworkViewParams = {
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 13;
|
||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
export function getWorkbenchPadding(
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
map: MapLibreMap,
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
rightPanelOpen: boolean,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth?: number
|
||||
): PaddingOptions {
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
agentWidth: agentPanelWidth,
|
||||
conditionOpen: rightPanelOpen,
|
||||
conditionExpanded: conditionPanelExpanded
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -41,14 +50,11 @@ export function fitNetworkBounds(
|
||||
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
|
||||
padding: PaddingOptions = GLOBAL_VIEW_PADDING
|
||||
) {
|
||||
map.fitBounds(
|
||||
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
|
||||
{
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
}
|
||||
);
|
||||
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
|
||||
padding: getResponsivePadding(map, padding),
|
||||
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
|
||||
duration: 600
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
@@ -84,10 +90,7 @@ function getMapViewportSize(map: MapLibreMap) {
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
return [
|
||||
webMercatorToLngLat(minX, minY),
|
||||
webMercatorToLngLat(maxX, maxY)
|
||||
];
|
||||
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
|
||||
}
|
||||
|
||||
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./map-colors";
|
||||
import { createBaseStyle } from "./sources";
|
||||
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
|
||||
const mapBackgrounds = [
|
||||
MAP_STYLE_TOKENS.canvas.primary,
|
||||
MAP_STYLE_TOKENS.canvas.secondary,
|
||||
@@ -47,18 +47,35 @@ describe("map color contrast", () => {
|
||||
});
|
||||
|
||||
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||
sourceColors.forEach(() => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps operational states above 3:1 on every map background", () => {
|
||||
const operationalColors = [
|
||||
MAP_STYLE_TOKENS.state.success,
|
||||
MAP_STYLE_TOKENS.state.risk,
|
||||
MAP_STYLE_TOKENS.state.incident,
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
MAP_STYLE_TOKENS.state.agent
|
||||
];
|
||||
|
||||
operationalColors.forEach((stateColor) => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
getContrastRatio(stateColor, background),
|
||||
createCoolorsContrastUrl(stateColor, background)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
|
||||
@@ -1,21 +1,177 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
|
||||
import {
|
||||
FLOW_LINE_LAYER_ID,
|
||||
FLOW_REVERSE_LINE_LAYER_ID,
|
||||
clearPipeFlowCalculations,
|
||||
createFlowOverlayState,
|
||||
getPipeFlowDirection,
|
||||
replacePipeFlowCalculations,
|
||||
showFlowOverlay,
|
||||
updatePipeFlowCalculation
|
||||
} from "./flow-overlay";
|
||||
|
||||
describe("flow overlay lifecycle", () => {
|
||||
it("starts empty so conduit data can be loaded once per map session", () => {
|
||||
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
|
||||
describe("flow overlay", () => {
|
||||
it("classifies signed velocities relative to pipe geometry", () => {
|
||||
expect(getPipeFlowDirection(2.5)).toBe(1);
|
||||
expect(getPipeFlowDirection(-0.01)).toBe(-1);
|
||||
expect(getPipeFlowDirection(0)).toBe(0);
|
||||
expect(getPipeFlowDirection(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels an active animation frame without clearing cached data", () => {
|
||||
const cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("window", { cancelAnimationFrame });
|
||||
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
|
||||
const { map, layers } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
state.data = [];
|
||||
state.animationFrameId = 42;
|
||||
stopFlowAnimation(state);
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
|
||||
expect(state.animationFrameId).toBeNull();
|
||||
expect(state.data).toBeTruthy();
|
||||
|
||||
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
|
||||
|
||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||
source: "pipes",
|
||||
"source-layer": "geo_pipes_mat",
|
||||
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
||||
});
|
||||
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
||||
source: "pipes",
|
||||
"source-layer": "geo_pipes_mat",
|
||||
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
||||
});
|
||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||
expect(
|
||||
JSON.stringify((layers.get(FLOW_LINE_LAYER_ID) as { paint: Record<string, unknown> }).paint["line-opacity"])
|
||||
).toContain('["%",["to-number",["id"],0],2]');
|
||||
});
|
||||
|
||||
it("updates one pipe direction without rebuilding its vector source", () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
|
||||
const first = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: -2.4
|
||||
});
|
||||
const second = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: 1.2
|
||||
});
|
||||
|
||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
||||
source: "pipes",
|
||||
sourceLayer: "geo_pipes_mat",
|
||||
id: "P-1"
|
||||
}, {
|
||||
flowDirection: -1
|
||||
});
|
||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
flowDirection: 1
|
||||
});
|
||||
expect(first).toMatchObject({ total: 1, reverse: 1 });
|
||||
expect(second).toMatchObject({ total: 1, forward: 1 });
|
||||
expect(map.getSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a snapshot and clears only stale flow state keys", async () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-old",
|
||||
velocity: 1
|
||||
});
|
||||
map.removeFeatureState.mockClear();
|
||||
|
||||
const summary = await replacePipeFlowCalculations(
|
||||
map as unknown as MapLibreMap,
|
||||
state,
|
||||
[
|
||||
{ pipeId: "P-new", velocity: -3 },
|
||||
{ pipeId: "P-new", velocity: 4 },
|
||||
{ pipeId: "", velocity: 1 }
|
||||
]
|
||||
);
|
||||
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "P-old"
|
||||
}), "flowDirection");
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted");
|
||||
expect(summary).toEqual({
|
||||
total: 1,
|
||||
forward: 1,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to the all-pipe mock after clearing explicit calculations", async () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
|
||||
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: 1
|
||||
});
|
||||
|
||||
await clearPipeFlowCalculations(map as unknown as MapLibreMap, state);
|
||||
|
||||
const latestOpacity = map.setPaintProperty.mock.calls
|
||||
.filter(([, property]) => property === "line-opacity")
|
||||
.at(-1)?.[2];
|
||||
expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]');
|
||||
});
|
||||
|
||||
it("applies large snapshots in animation-frame batches", async () => {
|
||||
const callbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback);
|
||||
return callbacks.length;
|
||||
}),
|
||||
cancelAnimationFrame: vi.fn()
|
||||
});
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
const records = Array.from({ length: 2_001 }, (_, index) => ({
|
||||
pipeId: index,
|
||||
velocity: index % 2 === 0 ? 1 : -1
|
||||
}));
|
||||
|
||||
const pending = replacePipeFlowCalculations(
|
||||
map as unknown as MapLibreMap,
|
||||
state,
|
||||
records
|
||||
);
|
||||
expect(map.setFeatureState).toHaveBeenCalledTimes(2_000);
|
||||
callbacks.shift()?.(16);
|
||||
const summary = await pending;
|
||||
|
||||
expect(map.setFeatureState).toHaveBeenCalledTimes(2_001);
|
||||
expect(summary.total).toBe(2_001);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
function createMap() {
|
||||
const layers = new Map<string, unknown>();
|
||||
const images = new Set<string>();
|
||||
const map = {
|
||||
addImage: vi.fn((id: string) => {
|
||||
images.add(id);
|
||||
}),
|
||||
addLayer: vi.fn((layer: { id: string }) => {
|
||||
layers.set(layer.id, layer);
|
||||
return map;
|
||||
}),
|
||||
getLayer: vi.fn((id: string) => layers.get(id)),
|
||||
getSource: vi.fn(),
|
||||
hasImage: vi.fn((id: string) => images.has(id)),
|
||||
removeFeatureState: vi.fn(),
|
||||
setFeatureState: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
setPaintProperty: vi.fn((id: string, property: string, value: unknown) => {
|
||||
const layer = layers.get(id) as { paint?: Record<string, unknown> } | undefined;
|
||||
if (layer?.paint) layer.paint[property] = value;
|
||||
}),
|
||||
triggerRepaint: vi.fn()
|
||||
};
|
||||
return { map, layers };
|
||||
}
|
||||
|
||||
@@ -1,99 +1,437 @@
|
||||
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
import type {
|
||||
ExpressionSpecification,
|
||||
Map as MapLibreMap,
|
||||
StyleImageInterface
|
||||
} from "maplibre-gl";
|
||||
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
|
||||
export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward";
|
||||
export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse";
|
||||
|
||||
const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward";
|
||||
const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse";
|
||||
const FLOW_STATE_DIRECTION = "flowDirection";
|
||||
const FLOW_PATTERN_WIDTH = 64;
|
||||
const FLOW_PATTERN_HEIGHT = 8;
|
||||
const FLOW_PATTERN_DURATION_MS = 1_200;
|
||||
|
||||
export type PipeFlowCalculation = {
|
||||
pipeId: string | number;
|
||||
velocity: number;
|
||||
};
|
||||
|
||||
export type PipeFlowDirection = -1 | 0 | 1;
|
||||
|
||||
export type PipeFlowSummary = {
|
||||
total: number;
|
||||
forward: number;
|
||||
reverse: number;
|
||||
stopped: number;
|
||||
invalid: number;
|
||||
};
|
||||
|
||||
type NormalizedPipeFlowCalculation = {
|
||||
pipeId: string | number;
|
||||
direction: PipeFlowDirection;
|
||||
};
|
||||
|
||||
export type FlowOverlayState = {
|
||||
data: FlowFeature[] | null;
|
||||
animationFrameId: number | null;
|
||||
overlay: MapboxOverlay | null;
|
||||
calculations: Map<string, NormalizedPipeFlowCalculation>;
|
||||
revision: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function createFlowOverlayState(): FlowOverlayState {
|
||||
return { data: null, animationFrameId: null, overlay: null };
|
||||
return {
|
||||
calculations: new Map(),
|
||||
revision: 0,
|
||||
visible: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function showFlowOverlay(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
loadData: () => Promise<FeatureCollection>,
|
||||
_state: FlowOverlayState,
|
||||
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
if (!state.data) {
|
||||
const collection = await loadData();
|
||||
state.data = collection.features.filter(isFlowFeature);
|
||||
}
|
||||
ensureFlowImages(map, reducedMotion);
|
||||
ensureFlowLayers(map);
|
||||
setFlowMockVisibility(map, _state.calculations.size === 0);
|
||||
_state.visible = true;
|
||||
setFlowLayerVisibility(map, "visible");
|
||||
}
|
||||
|
||||
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
|
||||
import("@deck.gl/mapbox"),
|
||||
import("@deck.gl/layers"),
|
||||
import("./flow-path-extension")
|
||||
]);
|
||||
const extension = new FlowPathExtension();
|
||||
let renderingError: Error | null = null;
|
||||
const render = (phase: number) => {
|
||||
const layer = new PathLayer<FlowFeature>({
|
||||
id: "workbench-network-flow",
|
||||
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
|
||||
data: state.data ?? [],
|
||||
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
|
||||
getColor: [15, 118, 110, 210],
|
||||
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
|
||||
widthUnits: "pixels",
|
||||
widthMinPixels: 1,
|
||||
widthMaxPixels: 5,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
pickable: false,
|
||||
parameters: { depthTest: false },
|
||||
extensions: [extension],
|
||||
flowPhase: phase
|
||||
} as never);
|
||||
state.overlay?.setProps({
|
||||
layers: [layer],
|
||||
onError: (error) => {
|
||||
renderingError = error;
|
||||
}
|
||||
});
|
||||
export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) {
|
||||
_state.visible = false;
|
||||
setFlowLayerVisibility(map, "none");
|
||||
}
|
||||
|
||||
export async function replacePipeFlowCalculations(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
records: readonly PipeFlowCalculation[]
|
||||
): Promise<PipeFlowSummary> {
|
||||
const revision = ++state.revision;
|
||||
const { calculations: next, invalid } = normalizePipeFlowCalculations(records);
|
||||
const changes: FlowStateChange[] = [];
|
||||
setFlowMockVisibility(map, false);
|
||||
|
||||
state.calculations.forEach((_, key) => {
|
||||
if (!next.has(key)) changes.push({ type: "remove", key });
|
||||
});
|
||||
next.forEach((calculation, key) => {
|
||||
const previous = state.calculations.get(key);
|
||||
if (
|
||||
previous?.direction !== calculation.direction
|
||||
) {
|
||||
changes.push({ type: "set", key, calculation });
|
||||
}
|
||||
});
|
||||
|
||||
if (state.visible && changes.length > 0) {
|
||||
setFlowLayerVisibility(map, "none");
|
||||
}
|
||||
await applyFlowStateChanges(map, state, changes, revision);
|
||||
if (state.visible && revision === state.revision) {
|
||||
setFlowLayerVisibility(map, "visible");
|
||||
}
|
||||
return summarizePipeFlowCalculations(state.calculations.values(), invalid);
|
||||
}
|
||||
|
||||
export function updatePipeFlowCalculation(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
record: PipeFlowCalculation
|
||||
): PipeFlowSummary {
|
||||
const calculation = normalizePipeFlowCalculation(record);
|
||||
if (!calculation) throw new Error("INVALID_FLOW_CALCULATION");
|
||||
state.revision += 1;
|
||||
const key = String(calculation.pipeId);
|
||||
const previous = state.calculations.get(key);
|
||||
if (
|
||||
previous?.direction !== calculation.direction
|
||||
) {
|
||||
setPipeFlowState(map, calculation);
|
||||
state.calculations.set(key, calculation);
|
||||
}
|
||||
if (state.visible) setFlowLayerVisibility(map, "visible");
|
||||
return summarizePipeFlowCalculations(state.calculations.values(), 0);
|
||||
}
|
||||
|
||||
export function clearPipeFlowCalculations(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState
|
||||
) {
|
||||
return replacePipeFlowCalculations(map, state, []).then((summary) => {
|
||||
if (state.visible) {
|
||||
setFlowMockVisibility(map, true);
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
export function getPipeFlowDirection(velocity: number): PipeFlowDirection {
|
||||
if (!Number.isFinite(velocity) || velocity === 0) return 0;
|
||||
return velocity < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
type FlowStateChange =
|
||||
| { type: "remove"; key: string }
|
||||
| {
|
||||
type: "set";
|
||||
key: string;
|
||||
calculation: NormalizedPipeFlowCalculation;
|
||||
};
|
||||
|
||||
if (!state.overlay) {
|
||||
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
|
||||
map.addControl(overlay);
|
||||
state.overlay = overlay;
|
||||
}
|
||||
function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) {
|
||||
const calculations = new Map<string, NormalizedPipeFlowCalculation>();
|
||||
let invalid = 0;
|
||||
records.forEach((record) => {
|
||||
const normalized = normalizePipeFlowCalculation(record);
|
||||
if (!normalized) {
|
||||
invalid += 1;
|
||||
return;
|
||||
}
|
||||
calculations.set(String(normalized.pipeId), normalized);
|
||||
});
|
||||
return { calculations, invalid };
|
||||
}
|
||||
|
||||
stopFlowAnimation(state);
|
||||
render(0.2);
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
if (renderingError) throw renderingError;
|
||||
if (!reducedMotion) {
|
||||
const startedAt = performance.now();
|
||||
const tick = (now: number) => {
|
||||
render(((now - startedAt) / 1800) % 1);
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
function normalizePipeFlowCalculation(
|
||||
record: PipeFlowCalculation
|
||||
): NormalizedPipeFlowCalculation | null {
|
||||
if (
|
||||
(typeof record.pipeId !== "string" && typeof record.pipeId !== "number")
|
||||
|| String(record.pipeId).trim().length === 0
|
||||
|| !Number.isFinite(record.velocity)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pipeId: record.pipeId,
|
||||
direction: getPipeFlowDirection(record.velocity)
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePipeFlowCalculations(
|
||||
calculations: Iterable<NormalizedPipeFlowCalculation>,
|
||||
invalid: number
|
||||
): PipeFlowSummary {
|
||||
const summary: PipeFlowSummary = {
|
||||
total: 0,
|
||||
forward: 0,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid
|
||||
};
|
||||
for (const calculation of calculations) {
|
||||
summary.total += 1;
|
||||
if (calculation.direction > 0) summary.forward += 1;
|
||||
else if (calculation.direction < 0) summary.reverse += 1;
|
||||
else summary.stopped += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function setPipeFlowState(
|
||||
map: MapLibreMap,
|
||||
calculation: NormalizedPipeFlowCalculation
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id: calculation.pipeId
|
||||
},
|
||||
{
|
||||
[FLOW_STATE_DIRECTION]: calculation.direction
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function applyFlowStateChanges(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
changes: readonly FlowStateChange[],
|
||||
revision: number
|
||||
) {
|
||||
const batchSize = 2_000;
|
||||
let offset = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const applyBatch = () => {
|
||||
if (revision !== state.revision) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const end = Math.min(offset + batchSize, changes.length);
|
||||
for (; offset < end; offset += 1) {
|
||||
const change = changes[offset];
|
||||
if (change.type === "remove") {
|
||||
removePipeFlowState(map, change.key);
|
||||
state.calculations.delete(change.key);
|
||||
} else {
|
||||
setPipeFlowState(map, change.calculation);
|
||||
state.calculations.set(change.key, change.calculation);
|
||||
}
|
||||
}
|
||||
if (offset >= changes.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(applyBatch);
|
||||
};
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
applyBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function removePipeFlowState(map: MapLibreMap, id: string | number) {
|
||||
const target = {
|
||||
source: "pipes",
|
||||
sourceLayer: SOURCE_LAYERS.pipes,
|
||||
id
|
||||
};
|
||||
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
|
||||
}
|
||||
|
||||
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
|
||||
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_FORWARD_IMAGE_ID,
|
||||
createFlowPatternImage(1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
|
||||
map.addImage(
|
||||
FLOW_REVERSE_IMAGE_ID,
|
||||
createFlowPatternImage(-1, reducedMotion),
|
||||
{ pixelRatio: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
|
||||
stopFlowAnimation(state);
|
||||
if (state.overlay) {
|
||||
map.removeControl(state.overlay as never);
|
||||
state.overlay = null;
|
||||
}
|
||||
function createFlowPatternImage(
|
||||
direction: -1 | 1,
|
||||
reducedMotion: boolean
|
||||
): StyleImageInterface {
|
||||
let map: MapLibreMap | null = null;
|
||||
let lastOffset = Number.NaN;
|
||||
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
|
||||
|
||||
const draw = (offset: number) => {
|
||||
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
|
||||
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
|
||||
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
|
||||
const wrapped = (
|
||||
x - offset + FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
const pulse = wrapped < 18
|
||||
? Math.sin((Math.PI * wrapped) / 18)
|
||||
: 0;
|
||||
const alpha = Math.round(255 * pulse * edgeFade);
|
||||
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
|
||||
data[index] = 94;
|
||||
data[index + 1] = 234;
|
||||
data[index + 2] = 212;
|
||||
data[index + 3] = alpha;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
|
||||
|
||||
return {
|
||||
width: FLOW_PATTERN_WIDTH,
|
||||
height: FLOW_PATTERN_HEIGHT,
|
||||
data,
|
||||
onAdd(nextMap) {
|
||||
map = nextMap;
|
||||
},
|
||||
render() {
|
||||
if (reducedMotion) return false;
|
||||
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
|
||||
/ FLOW_PATTERN_DURATION_MS;
|
||||
const offset = Math.floor(
|
||||
progress * FLOW_PATTERN_WIDTH * direction
|
||||
+ FLOW_PATTERN_WIDTH
|
||||
) % FLOW_PATTERN_WIDTH;
|
||||
if (offset === lastOffset) {
|
||||
map?.triggerRepaint();
|
||||
return false;
|
||||
}
|
||||
lastOffset = offset;
|
||||
draw(offset);
|
||||
map?.triggerRepaint();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function stopFlowAnimation(state: FlowOverlayState) {
|
||||
if (state.animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(state.animationFrameId);
|
||||
state.animationFrameId = null;
|
||||
const mockDirectionExpression: ExpressionSpecification = [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
["%", ["to-number", ["id"], 0], 2],
|
||||
0
|
||||
],
|
||||
1,
|
||||
-1
|
||||
];
|
||||
|
||||
const directionExpression = (
|
||||
direction: -1 | 1,
|
||||
visibleOpacity: number,
|
||||
mockVisible = false
|
||||
): ExpressionSpecification => [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
[
|
||||
"coalesce",
|
||||
["feature-state", FLOW_STATE_DIRECTION],
|
||||
mockVisible ? mockDirectionExpression : 0
|
||||
],
|
||||
direction
|
||||
],
|
||||
visibleOpacity,
|
||||
0
|
||||
];
|
||||
|
||||
function ensureFlowLayers(map: MapLibreMap) {
|
||||
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_FORWARD_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: FLOW_REVERSE_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||
layout: flowLineLayout(),
|
||||
paint: {
|
||||
"line-pattern": FLOW_REVERSE_IMAGE_ID,
|
||||
"line-width": 4,
|
||||
"line-opacity": directionExpression(-1, 0.92)
|
||||
}
|
||||
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function isFlowFeature(feature: Feature): feature is FlowFeature {
|
||||
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
|
||||
function flowLineLayout() {
|
||||
return {
|
||||
visibility: "visible" as const,
|
||||
"line-cap": "round" as const,
|
||||
"line-join": "round" as const
|
||||
};
|
||||
}
|
||||
|
||||
function setFlowLayerVisibility(
|
||||
map: MapLibreMap,
|
||||
visibility: "visible" | "none"
|
||||
) {
|
||||
[
|
||||
FLOW_LINE_LAYER_ID,
|
||||
FLOW_REVERSE_LINE_LAYER_ID
|
||||
].forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setFlowMockVisibility(
|
||||
map: MapLibreMap,
|
||||
visible: boolean
|
||||
) {
|
||||
const opacityByLayer = [
|
||||
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
|
||||
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
|
||||
] as const;
|
||||
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(
|
||||
layerId,
|
||||
property,
|
||||
directionExpression(direction, opacity, visible)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LayerExtension, type Layer } from "@deck.gl/core";
|
||||
|
||||
const flowPathUniforms = {
|
||||
name: "flowPath",
|
||||
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
|
||||
uniformTypes: { phase: "f32" }
|
||||
} as const;
|
||||
|
||||
export class FlowPathExtension extends LayerExtension {
|
||||
static extensionName = "FlowPathExtension";
|
||||
static defaultProps = { flowPhase: { type: "number", value: 0 } };
|
||||
|
||||
getShaders() {
|
||||
return {
|
||||
modules: [flowPathUniforms],
|
||||
inject: {
|
||||
"fs:DECKGL_FILTER_COLOR": `
|
||||
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
|
||||
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
|
||||
color.a *= 0.22 + flowPulse * 0.78;
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
draw(this: Layer) {
|
||||
const state = this.state as unknown as {
|
||||
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
|
||||
};
|
||||
state.model?.shaderInputs?.setProps({
|
||||
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
|
||||
opacity?: number;
|
||||
radiusMultiplier?: number;
|
||||
strokeMultiplier?: number;
|
||||
iconMultiplier?: number;
|
||||
};
|
||||
|
||||
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
|
||||
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
const values = patch as Record<string, unknown>;
|
||||
const allowed = geometry === "line"
|
||||
? ["color", "opacity", "widthMultiplier", "dash"]
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
|
||||
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
|
||||
if ("color" in values && !isColor(values.color)) return false;
|
||||
if ("fillColor" in values && !isColor(values.fillColor)) return false;
|
||||
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
|
||||
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
|
||||
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
|
||||
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
|
||||
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
if (patch.strokeMultiplier !== undefined) {
|
||||
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
|
||||
}
|
||||
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
|
||||
setLayout(
|
||||
map,
|
||||
`${groupId}-symbol`,
|
||||
"icon-size",
|
||||
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
|
||||
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
|
||||
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
|
||||
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
|
||||
if ("iconSize" in visual) {
|
||||
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
function scale(value: unknown, multiplier: number): unknown {
|
||||
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
|
||||
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
||||
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
|
||||
map.setLayoutProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
}
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
@@ -7,30 +7,31 @@ export const MAP_STYLE_TOKENS = {
|
||||
casing: "#FFFFFF",
|
||||
transparent: "rgba(0,0,0,0)"
|
||||
},
|
||||
asset: {
|
||||
conduit: "#0F766E",
|
||||
junction: "#0369A1",
|
||||
orifice: "#475569",
|
||||
pump: "#155E75",
|
||||
outfall: "#334155"
|
||||
},
|
||||
supply: {
|
||||
pipeMinor: "#5A9E8A",
|
||||
pipeMinor: "#3F7F70",
|
||||
pipeMedium: "#217C83",
|
||||
pipeMajor: "#245E75",
|
||||
junction: "#5B8E91",
|
||||
valve: "#8A8A8A",
|
||||
junction: "#47787B",
|
||||
valve: "#6B7280",
|
||||
reservoir: "#195F84",
|
||||
scadaFlow: "#0094BC",
|
||||
scadaPressure: "#FFB624"
|
||||
scadaFlow: "#007A9E",
|
||||
scadaPressure: "#9A6700"
|
||||
},
|
||||
// Map overlays stay more chromatic than UI status surfaces so they remain legible over imagery.
|
||||
state: {
|
||||
selected: "#2563EB",
|
||||
inactive: "#64748B",
|
||||
risk: "#C2410C",
|
||||
incident: "#B91C1C",
|
||||
success: "#15803D",
|
||||
agent: "#6D28D9"
|
||||
risk: "#C66A16",
|
||||
incident: "#C34A4A",
|
||||
success: "#2E8B64",
|
||||
agent: "#7656C7"
|
||||
},
|
||||
stateLabel: {
|
||||
neutral: "#334155",
|
||||
warning: "#8C470D",
|
||||
danger: "#9C3036",
|
||||
success: "#1F6F4D",
|
||||
agent: "#573E9C"
|
||||
},
|
||||
tool: {
|
||||
geometry: "#334155"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_CONDUIT_FEATURES,
|
||||
MAX_PIPE_FEATURES,
|
||||
MAP_FEATURE_ID_FIELDS,
|
||||
createMapFeatureWfsUrl,
|
||||
escapeCqlLiteral,
|
||||
@@ -20,7 +20,7 @@ describe("map feature WFS query", () => {
|
||||
tanks: "id"
|
||||
});
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "junctions", featureIds: ["J-1"] }).searchParams.get("cql_filter"))
|
||||
.toBe("id IN ('J-1')");
|
||||
.toBe("\"id\" IN ('J-1')");
|
||||
});
|
||||
|
||||
it("escapes CQL literals and uses materialized WFS layers", () => {
|
||||
@@ -28,7 +28,7 @@ describe("map feature WFS query", () => {
|
||||
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
|
||||
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
|
||||
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
|
||||
expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')");
|
||||
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
||||
});
|
||||
|
||||
it("uses the authoritative supply asset WFS layers", () => {
|
||||
@@ -39,7 +39,7 @@ describe("map feature WFS query", () => {
|
||||
|
||||
it("limits ids and permits full-network requests only for pipes", () => {
|
||||
expect(parseMapFeatureQuery({ sourceId: "pipes" })).toEqual({ ok: true, value: { sourceId: "pipes" } });
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_FEATURES));
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
|
||||
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
||||
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
|
||||
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
|
||||
|
||||
export const MAX_MAP_FEATURE_IDS = 100;
|
||||
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
|
||||
export const MAX_CONDUIT_FEATURES = 5000;
|
||||
export const MAX_PIPE_FEATURES = 5000;
|
||||
|
||||
export type MapFeatureQuery = {
|
||||
sourceId: WaterNetworkSourceId;
|
||||
@@ -75,11 +75,11 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
outputFormat: "application/json",
|
||||
srsName: "EPSG:4326",
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES)
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
|
||||
});
|
||||
|
||||
if (query.featureIds) {
|
||||
params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
}
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
|
||||
@@ -15,10 +15,10 @@ describe("SCADA analysis overlay", () => {
|
||||
const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator);
|
||||
const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label);
|
||||
expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({
|
||||
high: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
high: "#C34A4A",
|
||||
medium: "#C66A16",
|
||||
low: "#2E8B64",
|
||||
unrated: "#7656C7"
|
||||
});
|
||||
expect(casing).toMatchObject({
|
||||
type: "circle",
|
||||
@@ -49,9 +49,10 @@ describe("SCADA analysis overlay", () => {
|
||||
},
|
||||
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 4, "text-halo-blur": 0.5 }
|
||||
});
|
||||
expect(JSON.stringify(label)).toContain("#991B1B");
|
||||
expect(JSON.stringify(label)).toContain("#92400E");
|
||||
expect(JSON.stringify(label)).toContain("#166534");
|
||||
expect(JSON.stringify(label)).toContain("#9C3036");
|
||||
expect(JSON.stringify(label)).toContain("#8C470D");
|
||||
expect(JSON.stringify(label)).toContain("#1F6F4D");
|
||||
expect(JSON.stringify(label)).toContain("#573E9C");
|
||||
});
|
||||
|
||||
it("validates one to one hundred unique IDs and fixed levels", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
||||
export const SCADA_ANALYSIS_LAYER_IDS = {
|
||||
@@ -21,17 +22,17 @@ export type ScadaAnalysisRenderResult = {
|
||||
};
|
||||
|
||||
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
||||
high: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
high: MAP_STYLE_TOKENS.state.incident,
|
||||
medium: MAP_STYLE_TOKENS.state.risk,
|
||||
low: MAP_STYLE_TOKENS.state.success,
|
||||
unrated: MAP_STYLE_TOKENS.state.agent
|
||||
};
|
||||
|
||||
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
||||
high: "#991B1B",
|
||||
medium: "#92400E",
|
||||
low: "#166534",
|
||||
unrated: "#5B21B6"
|
||||
high: MAP_STYLE_TOKENS.stateLabel.danger,
|
||||
medium: MAP_STYLE_TOKENS.stateLabel.warning,
|
||||
low: MAP_STYLE_TOKENS.stateLabel.success,
|
||||
unrated: MAP_STYLE_TOKENS.stateLabel.agent
|
||||
};
|
||||
|
||||
const levelColorExpression = (
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
||||
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection
|
||||
} from "./scada-analysis";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
bbox3857: [
|
||||
13508801.930066336,
|
||||
3608163.3499832638,
|
||||
13555650.638648884,
|
||||
3633685.137885742
|
||||
]
|
||||
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
|
||||
} as const;
|
||||
|
||||
export const SOURCE_LAYERS = {
|
||||
@@ -102,21 +101,27 @@ export const SUPPLY_LAYER_CATALOG = [
|
||||
}
|
||||
] as const satisfies readonly SupplyLayerCatalogItem[];
|
||||
|
||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.available)
|
||||
.map((layer) => layer.id) as Extract<WaterNetworkSourceId, "pipes" | "junctions" | "valves" | "reservoirs" | "scada">[];
|
||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
||||
(layer) => layer.available
|
||||
).map((layer) => layer.id) as Extract<
|
||||
WaterNetworkSourceId,
|
||||
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
|
||||
>[];
|
||||
|
||||
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
||||
|
||||
export function isAvailableWaterNetworkSourceId(value: string): value is AvailableWaterNetworkSourceId {
|
||||
export function isAvailableWaterNetworkSourceId(
|
||||
value: string
|
||||
): value is AvailableWaterNetworkSourceId {
|
||||
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function createWaterNetworkSources() {
|
||||
return Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.available)
|
||||
.map((layer) => [layer.id, createGeoServerVectorSource(layer.sourceLayer)])
|
||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
|
||||
layer.id,
|
||||
createGeoServerVectorSource(layer.sourceLayer)
|
||||
])
|
||||
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
|
||||
}
|
||||
|
||||
@@ -135,7 +140,12 @@ function createGeoServerVectorSource(
|
||||
}
|
||||
|
||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
const sources: StyleSpecification["sources"] = {};
|
||||
const sources: StyleSpecification["sources"] = {
|
||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||
type: "geojson",
|
||||
data: createEmptyScadaAnalysisCollection()
|
||||
}
|
||||
};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "background",
|
||||
@@ -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]]], {
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
});
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
],
|
||||
{
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
}
|
||||
);
|
||||
}
|
||||
return result;
|
||||
*/
|
||||
}
|
||||
|
||||
clearScadaAnalysis() {
|
||||
@@ -256,19 +415,31 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
resetAll = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
const map = this.options.getMap();
|
||||
if (map) {
|
||||
hideFlowOverlay(map, this.flowState);
|
||||
this.flowUpdateRevision += 1;
|
||||
void clearFlowCalculations(map, this.flowState);
|
||||
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
|
||||
}
|
||||
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
|
||||
}
|
||||
this.patchState({
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
});
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.flowUpdateRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.listeners.clear();
|
||||
@@ -299,15 +470,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
||||
const response = await fetch("/api/map-features", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId, featureIds }),
|
||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||
if (!parsed.ok) throw new Error(parsed.code);
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||
headers: { Accept: "application/geo+json, application/json" },
|
||||
signal
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
|
||||
return body as FeatureCollection;
|
||||
if (!response.ok) throw new Error("WFS_UNAVAILABLE");
|
||||
const maxFeatures = featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES;
|
||||
const collection = normalizeMapFeatureCollection(await response.json(), maxFeatures);
|
||||
if (!collection) throw new Error("WFS_UNAVAILABLE");
|
||||
return collection;
|
||||
}
|
||||
|
||||
private setHighlight(target: FeatureTarget) {
|
||||
@@ -316,7 +489,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||
this.highlightedTarget = target;
|
||||
this.clearValueLabel();
|
||||
this.patchState({ target, errorCode: null });
|
||||
}
|
||||
|
||||
@@ -339,6 +511,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyPipeFlowSummary(): PipeFlowSummary {
|
||||
return { total: 0, forward: 0, reverse: 0, stopped: 0, invalid: 0 };
|
||||
}
|
||||
|
||||
function toFeatureStateTarget(target: FeatureTarget) {
|
||||
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import {
|
||||
canCenterTimelineBesideMapScale,
|
||||
clusterOperationalEvents,
|
||||
createOperationalEvents,
|
||||
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
filterOperationalEvents,
|
||||
getOperationalEventCategory,
|
||||
OPERATIONAL_TIMELINE_LAYOUT,
|
||||
resolveTimelineScaleLayout
|
||||
} from "./operational-timeline-model";
|
||||
import type { OperationalEvent } from "./operational-timeline-model";
|
||||
|
||||
const workOrder: ScheduledConditionItem = {
|
||||
id: "work-1",
|
||||
kind: "work_order",
|
||||
code: "WO-1",
|
||||
scheduledAt: "2026-08-19T10:00:00.000Z",
|
||||
title: "阀门调整",
|
||||
summary: "调整阀门",
|
||||
status: "running",
|
||||
riskLevel: "attention",
|
||||
updatedAt: 1,
|
||||
durationMinutes: 40,
|
||||
source: "调度方案",
|
||||
location: "边界阀门 BV-07",
|
||||
dispatcher: "调度",
|
||||
assignee: "班组",
|
||||
priority: "urgent",
|
||||
replyWindowMinutes: 20,
|
||||
stages: [],
|
||||
replyRequirements: []
|
||||
};
|
||||
|
||||
describe("operational timeline model", () => {
|
||||
it("does not reserve workspace layout space for the floating timeline", () => {
|
||||
expect(OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight).toBe(0);
|
||||
});
|
||||
|
||||
it("creates linked plan and actual events for active work orders", () => {
|
||||
const events = createOperationalEvents([workOrder]);
|
||||
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
|
||||
expect(events[0].correlationId).toBe(events[1].correlationId);
|
||||
});
|
||||
|
||||
it("filters event lanes from the interactive legend", () => {
|
||||
const events = createOperationalEvents([workOrder]);
|
||||
const filtered = filterOperationalEvents(events, {
|
||||
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
actual: false
|
||||
});
|
||||
|
||||
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
|
||||
});
|
||||
|
||||
it("separates attention events from failed exceptions", () => {
|
||||
const [planEvent] = createOperationalEvents([workOrder]);
|
||||
const attentionEvent = {
|
||||
...planEvent,
|
||||
id: "attention-1",
|
||||
lane: "exception",
|
||||
status: "delayed"
|
||||
} satisfies OperationalEvent;
|
||||
const exceptionEvent = {
|
||||
...attentionEvent,
|
||||
id: "exception-1",
|
||||
status: "failed"
|
||||
} satisfies OperationalEvent;
|
||||
|
||||
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
|
||||
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
|
||||
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
|
||||
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
attention: false
|
||||
})).toEqual([exceptionEvent]);
|
||||
});
|
||||
|
||||
it("only promotes abnormal condition runs into exception events", () => {
|
||||
const normal = {
|
||||
id: "condition-normal",
|
||||
kind: "condition",
|
||||
taskId: "scada-diagnosis",
|
||||
sessionId: "s-1",
|
||||
scheduledAt: "2026-08-19T10:00:00.000Z",
|
||||
title: "诊断",
|
||||
summary: "正常",
|
||||
status: "completed",
|
||||
riskLevel: "normal",
|
||||
updatedAt: 1
|
||||
} satisfies ScheduledConditionItem;
|
||||
const abnormal = {
|
||||
...normal,
|
||||
id: "condition-error",
|
||||
status: "error",
|
||||
riskLevel: "critical"
|
||||
} satisfies ScheduledConditionItem;
|
||||
|
||||
expect(createOperationalEvents([normal])).toHaveLength(0);
|
||||
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
|
||||
});
|
||||
|
||||
it("aggregates nearby events into ten minute lane buckets", () => {
|
||||
const events = createOperationalEvents([workOrder]);
|
||||
const nearbyPlan = {
|
||||
...events[0],
|
||||
id: "plan-nearby",
|
||||
cursorTime: "2026-08-19T10:05:00.000Z"
|
||||
};
|
||||
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
|
||||
expect(clusters).toHaveLength(2);
|
||||
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => {
|
||||
expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true);
|
||||
expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false);
|
||||
expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => {
|
||||
expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({
|
||||
scaleBottom: 0,
|
||||
timelineBottom: 24
|
||||
});
|
||||
expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({
|
||||
scaleBottom: 0,
|
||||
timelineBottom: 56
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { FeatureTarget } from "../map/workbench-map-controller";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionRecord,
|
||||
ScheduledWorkOrderItem
|
||||
} from "../types";
|
||||
|
||||
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
|
||||
export type OperationalEventLane = "plan" | "actual" | "exception";
|
||||
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
|
||||
export type OperationalEventImportance = "normal" | "important" | "critical";
|
||||
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
|
||||
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
|
||||
|
||||
export type OperationalEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
lane: OperationalEventLane;
|
||||
status: OperationalEventStatus;
|
||||
importance: OperationalEventImportance;
|
||||
plannedTime: string | null;
|
||||
actualTime: string | null;
|
||||
cursorTime: string;
|
||||
correlationId: string | null;
|
||||
scheduleId: string;
|
||||
conditionId: string | null;
|
||||
sourceLabel: string;
|
||||
mapTargets: FeatureTarget[];
|
||||
};
|
||||
|
||||
export type OperationalEventCluster = {
|
||||
id: string;
|
||||
lane: OperationalEventLane;
|
||||
bucketStart: number;
|
||||
events: OperationalEvent[];
|
||||
};
|
||||
|
||||
export const OPERATIONAL_TIMELINE_LAYOUT = {
|
||||
compact: { height: 88, maxWidth: 980 },
|
||||
expanded: { height: 240, maxWidth: 1180 },
|
||||
summary: { height: 52, maxWidth: 760 },
|
||||
mobileSummaryHeight: 48,
|
||||
mapScaleSafeWidth: 520,
|
||||
floatingGap: 48,
|
||||
edgeInset: 24,
|
||||
scaleCollisionOffset: 56,
|
||||
overlayReservedHeight: 0
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
|
||||
plan: true,
|
||||
actual: true,
|
||||
attention: true,
|
||||
exception: true
|
||||
};
|
||||
|
||||
export type TimelineScaleLayout = {
|
||||
scaleBottom: number;
|
||||
timelineBottom: number;
|
||||
};
|
||||
|
||||
export function canCenterTimelineBesideMapScale(
|
||||
businessWorkspaceWidth: number,
|
||||
mode: OperationalTimelineMode,
|
||||
visible: boolean
|
||||
) {
|
||||
if (!visible) return true;
|
||||
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
|
||||
return (
|
||||
businessWorkspaceWidth >=
|
||||
layout.maxWidth +
|
||||
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineScaleLayout(
|
||||
businessWorkspaceWidth: number,
|
||||
mode: OperationalTimelineMode,
|
||||
visible: boolean
|
||||
): TimelineScaleLayout {
|
||||
const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale(
|
||||
businessWorkspaceWidth,
|
||||
mode,
|
||||
visible
|
||||
);
|
||||
|
||||
return {
|
||||
scaleBottom: 0,
|
||||
timelineBottom:
|
||||
visible && !scaleClearsCenteredTimeline
|
||||
? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset
|
||||
: OPERATIONAL_TIMELINE_LAYOUT.edgeInset
|
||||
};
|
||||
}
|
||||
|
||||
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
||||
return items
|
||||
.flatMap((item) =>
|
||||
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||
)
|
||||
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||
}
|
||||
|
||||
export function clusterOperationalEvents(
|
||||
events: OperationalEvent[],
|
||||
bucketMinutes = 10
|
||||
): OperationalEventCluster[] {
|
||||
const bucketMs = bucketMinutes * 60_000;
|
||||
const clusters = new Map<string, OperationalEventCluster>();
|
||||
|
||||
events.forEach((event) => {
|
||||
const timestamp = Date.parse(event.cursorTime);
|
||||
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
|
||||
const key = `${event.lane}-${bucketStart}`;
|
||||
const cluster = clusters.get(key) ?? {
|
||||
id: key,
|
||||
lane: event.lane,
|
||||
bucketStart,
|
||||
events: []
|
||||
};
|
||||
cluster.events.push(event);
|
||||
clusters.set(key, cluster);
|
||||
});
|
||||
|
||||
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
|
||||
}
|
||||
|
||||
export function getOperationalEventSummary(events: OperationalEvent[]) {
|
||||
return {
|
||||
planned: events.filter((event) => event.lane === "plan").length,
|
||||
active: events.filter((event) => event.lane === "actual").length,
|
||||
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
|
||||
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
|
||||
critical: events.filter((event) => event.importance === "critical").length
|
||||
};
|
||||
}
|
||||
|
||||
export function getOperationalEventCategory(
|
||||
event: OperationalEvent
|
||||
): OperationalEventCategory {
|
||||
if (event.lane === "plan" || event.lane === "actual") return event.lane;
|
||||
return event.status === "failed" ? "exception" : "attention";
|
||||
}
|
||||
|
||||
export function filterOperationalEvents(
|
||||
events: OperationalEvent[],
|
||||
visibility: OperationalEventVisibility
|
||||
) {
|
||||
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
|
||||
}
|
||||
|
||||
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
|
||||
if (item.status !== "warning" && item.status !== "error") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: `exception-${item.id}`,
|
||||
title: item.title,
|
||||
description: item.report?.conclusion ?? item.summary,
|
||||
lane: "exception",
|
||||
status: item.status === "error" ? "failed" : "delayed",
|
||||
importance: item.status === "error" ? "critical" : "important",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: item.scheduledAt,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.sessionId,
|
||||
scheduleId: item.taskId,
|
||||
conditionId: item.id,
|
||||
sourceLabel: "工况诊断",
|
||||
mapTargets: getConditionMapTargets(item)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
|
||||
const planEvent: OperationalEvent = {
|
||||
id: `plan-${item.id}`,
|
||||
title: item.title,
|
||||
description: `${item.location} · ${item.assignee}`,
|
||||
lane: "plan",
|
||||
status: "planned",
|
||||
importance: item.priority === "urgent" ? "important" : "normal",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: null,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.code,
|
||||
scheduleId: item.id,
|
||||
conditionId: item.id,
|
||||
sourceLabel: item.source,
|
||||
mapTargets: getWorkOrderMapTargets(item)
|
||||
};
|
||||
|
||||
if (item.status === "pending") {
|
||||
return [planEvent];
|
||||
}
|
||||
|
||||
const start = Date.parse(item.scheduledAt);
|
||||
const actualTime = new Date(
|
||||
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
|
||||
).toISOString();
|
||||
const actualEvent: OperationalEvent = {
|
||||
...planEvent,
|
||||
id: `actual-${item.id}`,
|
||||
lane: "actual",
|
||||
status: "executed",
|
||||
actualTime,
|
||||
cursorTime: actualTime,
|
||||
description:
|
||||
item.status === "running"
|
||||
? `${item.assignee}正在执行,位置:${item.location}`
|
||||
: `${item.assignee}已完成现场执行,等待调度复令`
|
||||
};
|
||||
|
||||
return [planEvent, actualEvent];
|
||||
}
|
||||
|
||||
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
|
||||
if (item.taskId === "scada-diagnosis") {
|
||||
return [{ sourceId: "scada", featureId: "SCADA-07" }];
|
||||
}
|
||||
if (item.taskId === "pump-energy") {
|
||||
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
|
||||
if (item.location.includes("阀")) {
|
||||
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
|
||||
}
|
||||
if (item.location.includes("流量计")) {
|
||||
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractAssetId(location: string) {
|
||||
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
Clock3,
|
||||
OctagonAlert,
|
||||
PanelBottomClose,
|
||||
PanelBottomOpen,
|
||||
RotateCcw,
|
||||
TriangleAlert
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
clusterOperationalEvents,
|
||||
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
filterOperationalEvents,
|
||||
getOperationalEventSummary,
|
||||
OPERATIONAL_TIMELINE_LAYOUT,
|
||||
type OperationalEvent,
|
||||
type OperationalEventCategory,
|
||||
type OperationalEventCluster,
|
||||
type OperationalEventLane,
|
||||
type OperationalEventVisibility,
|
||||
type OperationalTimelineMode
|
||||
} from "./operational-timeline-model";
|
||||
|
||||
type OperationalTimelineProps = {
|
||||
date: string;
|
||||
cursorTime: string;
|
||||
events: OperationalEvent[];
|
||||
mode: OperationalTimelineMode;
|
||||
selectedEventId: string | null;
|
||||
mobile?: boolean;
|
||||
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
onClearSelection: () => void;
|
||||
onReturnNow: () => void;
|
||||
};
|
||||
|
||||
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
||||
{ id: "plan", label: "计划" },
|
||||
{ id: "actual", label: "实际" },
|
||||
{ id: "exception", label: "异常" }
|
||||
];
|
||||
|
||||
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
|
||||
{ id: "plan", label: "计划" },
|
||||
{ id: "actual", label: "实际" },
|
||||
{ id: "attention", label: "关注" },
|
||||
{ id: "exception", label: "异常" }
|
||||
];
|
||||
|
||||
export function OperationalTimeline({
|
||||
date,
|
||||
cursorTime,
|
||||
events,
|
||||
mode,
|
||||
selectedEventId,
|
||||
mobile = false,
|
||||
onModeChange,
|
||||
onSelectEvent,
|
||||
onClearSelection,
|
||||
onReturnNow
|
||||
}: OperationalTimelineProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
|
||||
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
|
||||
}));
|
||||
const summary = getOperationalEventSummary(events);
|
||||
const visibleEvents = filterOperationalEvents(events, eventVisibility);
|
||||
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
|
||||
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
||||
const cursorPosition = getDayPosition(cursorTime);
|
||||
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
|
||||
const sizeTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
|
||||
|
||||
if (mode === "summary" && !mobile) {
|
||||
return (
|
||||
<motion.section
|
||||
aria-label="运行时间轴摘要"
|
||||
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
||||
initial={false}
|
||||
animate={{ height }}
|
||||
transition={sizeTransition}
|
||||
>
|
||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
||||
<span className="h-4 w-px bg-slate-300" />
|
||||
<TimelineSummary summary={summary} />
|
||||
{selectedEvent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
|
||||
>
|
||||
已选 {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
||||
)}
|
||||
<TimelineLegend
|
||||
visibility={eventVisibility}
|
||||
onVisibilityChange={setEventVisibility}
|
||||
/>
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<motion.section
|
||||
aria-label="运行时间轴"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||
mobile
|
||||
? "surface-reading h-full rounded-t-xl border-t"
|
||||
: "acrylic-panel rounded-2xl border"
|
||||
)}
|
||||
initial={false}
|
||||
animate={{ height }}
|
||||
transition={sizeTransition}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||
mode === "compact" && !mobile ? "h-10" : "h-12"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
|
||||
((mode === "compact" && !mobile) || mobile) && "hidden"
|
||||
)}
|
||||
>
|
||||
运行时间轴
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
||||
{!mobile ? <TimelineSummary summary={summary} /> : null}
|
||||
{selectedEvent && !mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
title="清除当前事件选择"
|
||||
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
|
||||
>
|
||||
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||
</button>
|
||||
) : null}
|
||||
<TimelineLegend
|
||||
visibility={eventVisibility}
|
||||
onVisibilityChange={setEventVisibility}
|
||||
/>
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{mode === "expanded" || mobile ? (
|
||||
<motion.div
|
||||
key="expanded-timeline"
|
||||
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
>
|
||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
|
||||
<span />
|
||||
{LANES.map((lane) => (
|
||||
<span key={lane.id} className="flex items-center justify-center font-semibold">
|
||||
{lane.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
||||
<TimeTicks />
|
||||
{LANES.map((lane) => (
|
||||
<TimelineLane
|
||||
key={lane.id}
|
||||
lane={lane.id}
|
||||
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
>
|
||||
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="compact-timeline"
|
||||
className="relative min-h-0 flex-1 px-3"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
>
|
||||
<CompactTimeTicks />
|
||||
<div className="absolute bottom-2 left-3 right-3 top-4">
|
||||
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
||||
{clusters.map((cluster, index) => (
|
||||
<TimelineMarker
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
compact
|
||||
markerIndex={index}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.section>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineLane({
|
||||
lane,
|
||||
clusters,
|
||||
selectedEventId,
|
||||
onSelectEvent
|
||||
}: {
|
||||
lane: OperationalEventLane;
|
||||
clusters: OperationalEventCluster[];
|
||||
selectedEventId: string | null;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative border-t border-slate-200" data-lane={lane}>
|
||||
{clusters.map((cluster, index) => (
|
||||
<TimelineMarker
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
markerIndex={index}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMarker({
|
||||
cluster,
|
||||
compact = false,
|
||||
markerIndex,
|
||||
selectedEventId,
|
||||
onSelectEvent
|
||||
}: {
|
||||
cluster: OperationalEventCluster;
|
||||
compact?: boolean;
|
||||
markerIndex: number;
|
||||
selectedEventId: string | null;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
}) {
|
||||
const event = pickClusterEvent(cluster.events);
|
||||
const selected = cluster.events.some((item) => item.id === selectedEventId);
|
||||
const position = getDayPosition(event.cursorTime);
|
||||
const exception = event.lane === "exception";
|
||||
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
|
||||
onClick={() => onSelectEvent(event)}
|
||||
className={cn(
|
||||
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
|
||||
compact ? "h-2 w-2" : "h-5 w-5",
|
||||
event.lane === "plan" &&
|
||||
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
|
||||
event.lane === "actual" &&
|
||||
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
|
||||
exception &&
|
||||
event.status !== "failed" &&
|
||||
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
|
||||
event.status === "failed" &&
|
||||
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
|
||||
event.importance === "critical" && !compact && "h-6 w-6",
|
||||
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
|
||||
)}
|
||||
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
|
||||
>
|
||||
{!compact ? <OperationalEventGlyph event={event} /> : null}
|
||||
{cluster.events.length > 1 && !compact ? (
|
||||
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
|
||||
{cluster.events.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
|
||||
)}
|
||||
>
|
||||
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
|
||||
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
|
||||
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
event.lane === "exception" &&
|
||||
event.status !== "failed" &&
|
||||
"border-amber-200 bg-amber-50 text-amber-800",
|
||||
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
|
||||
)}
|
||||
>
|
||||
<OperationalEventGlyph event={event} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold tracking-[0.08em]",
|
||||
event.lane === "plan" && "text-blue-700",
|
||||
event.lane === "actual" && "text-emerald-700",
|
||||
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
|
||||
event.status === "failed" && "text-rose-700"
|
||||
)}
|
||||
>
|
||||
{operationalEventNatureLabel(event)}
|
||||
</span>
|
||||
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
|
||||
{formatClock(event.cursorTime)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
|
||||
{event.title}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
|
||||
{event.description}
|
||||
</p>
|
||||
{cluster.events.length > 1 ? (
|
||||
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
||||
if (event.status === "failed") {
|
||||
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||
}
|
||||
if (event.lane === "exception") {
|
||||
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||
}
|
||||
if (event.lane === "actual") {
|
||||
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
|
||||
}
|
||||
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function operationalEventNatureLabel(event: OperationalEvent) {
|
||||
if (event.status === "failed") return "异常";
|
||||
if (event.lane === "exception") return "关注";
|
||||
if (event.lane === "actual") return "实际执行";
|
||||
return "计划任务";
|
||||
}
|
||||
|
||||
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
|
||||
<span>
|
||||
计划 <strong className="text-blue-700">{summary.planned}</strong>
|
||||
</span>
|
||||
<span>
|
||||
执行 <strong className="text-emerald-700">{summary.active}</strong>
|
||||
</span>
|
||||
<span>
|
||||
关注 <strong className="text-amber-700">{summary.attention}</strong>
|
||||
</span>
|
||||
<span>
|
||||
异常 <strong className="text-rose-700">{summary.exceptions}</strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineActions({
|
||||
mode,
|
||||
onModeChange,
|
||||
onReturnNow
|
||||
}: {
|
||||
mode: OperationalTimelineMode;
|
||||
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||
onReturnNow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
||||
<button
|
||||
type="button"
|
||||
title="返回现在"
|
||||
aria-label="返回现在"
|
||||
onClick={onReturnNow}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||
>
|
||||
<RotateCcw size={13} aria-hidden="true" />
|
||||
<span className="hidden sm:inline">现在</span>
|
||||
</button>
|
||||
{onModeChange ? (
|
||||
<>
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||
>
|
||||
{mode === "expanded" ? (
|
||||
<PanelBottomClose size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<PanelBottomOpen size={14} aria-hidden="true" />
|
||||
)}
|
||||
<span>{mode === "expanded" ? "收起" : "展开"}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineLegend({
|
||||
visibility,
|
||||
onVisibilityChange
|
||||
}: {
|
||||
visibility: OperationalEventVisibility;
|
||||
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label="时间轴图例"
|
||||
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
|
||||
>
|
||||
{LEGEND_ITEMS.map((item) => {
|
||||
const visible = visibility[item.id];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||
aria-pressed={visible}
|
||||
onClick={() =>
|
||||
onVisibilityChange({
|
||||
...visibility,
|
||||
[item.id]: !visible
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
|
||||
visible ? "opacity-100" : "grayscale opacity-25"
|
||||
)}
|
||||
>
|
||||
<LegendSwatch category={item.id} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"block h-3.5 w-3.5 shrink-0 border",
|
||||
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
|
||||
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
|
||||
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
|
||||
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeTicks() {
|
||||
return (
|
||||
<div className="relative text-[9px] text-slate-600">
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span
|
||||
key={hour}
|
||||
className={cn(
|
||||
"absolute",
|
||||
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
)}
|
||||
style={{ left: `${(hour / 24) * 100}%` }}
|
||||
>
|
||||
{String(hour).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactTimeTicks() {
|
||||
return (
|
||||
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span
|
||||
key={hour}
|
||||
className={cn(
|
||||
"absolute",
|
||||
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
)}
|
||||
style={{ left: `${(hour / 24) * 100}%` }}
|
||||
>
|
||||
{String(hour).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickClusterEvent(events: OperationalEvent[]) {
|
||||
return [...events].sort(
|
||||
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
|
||||
)[0];
|
||||
}
|
||||
|
||||
function importanceRank(value: OperationalEvent["importance"]) {
|
||||
return value === "critical" ? 2 : value === "important" ? 1 : 0;
|
||||
}
|
||||
|
||||
function getDayPosition(value: string) {
|
||||
const date = new Date(value);
|
||||
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
|
||||
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
|
||||
}
|
||||
|
||||
function formatClock(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const [year, month, day] = value.split("-");
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export type WorkbenchUser = {
|
||||
|
||||
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
||||
|
||||
export type ScheduledWorkOrderStatus = "pending" | "running" | "awaiting_reply";
|
||||
|
||||
export type ScheduledConditionItemStatus = ScheduledConditionStatus | ScheduledWorkOrderStatus;
|
||||
|
||||
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
||||
|
||||
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
||||
@@ -71,12 +75,12 @@ export type ScheduledConditionReport = {
|
||||
}[];
|
||||
};
|
||||
|
||||
type ScheduledConditionBase = {
|
||||
type ScheduledConditionBase<TStatus extends ScheduledConditionItemStatus> = {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: ScheduledConditionStatus;
|
||||
status: TStatus;
|
||||
riskLevel: ScheduledConditionRiskLevel;
|
||||
updatedAt: number;
|
||||
detail?: string;
|
||||
@@ -84,7 +88,7 @@ type ScheduledConditionBase = {
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledConditionStatus> & {
|
||||
kind: "condition";
|
||||
taskId: ScheduledConditionTaskId;
|
||||
sessionId: string;
|
||||
@@ -95,7 +99,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
report?: ScheduledConditionReport;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
||||
kind: "work_order";
|
||||
code: string;
|
||||
source: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getRunningWorkflowDefinition } from "../data/running-workflows";
|
||||
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
|
||||
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
awaiting_reply: "待复令",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { AnalysisArtifact } from "./workspace-model";
|
||||
|
||||
type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
|
||||
|
||||
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "diagnosis",
|
||||
lifecycle: "reviewed",
|
||||
revision: 3,
|
||||
title: "北辰分区压力异常诊断",
|
||||
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
|
||||
scope: "北辰供水分区 · 最近 6 小时",
|
||||
confidence: "92.6%",
|
||||
generatedAt,
|
||||
analyticalTimeRange: {
|
||||
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||
},
|
||||
preferredView: "map_split",
|
||||
mapRelation: "required",
|
||||
blocks: [
|
||||
{
|
||||
id: "pressure-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "诊断摘要",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
|
||||
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
|
||||
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
|
||||
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "pressure-trend",
|
||||
kind: "chart",
|
||||
title: "关键节点压力曲线",
|
||||
description: "BC-P-014 在 09:40 后持续低于调度下限",
|
||||
span: 7,
|
||||
chartType: "line",
|
||||
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
|
||||
unit: "MPa",
|
||||
series: [
|
||||
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
|
||||
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
|
||||
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "pressure-ranking",
|
||||
kind: "chart",
|
||||
title: "异常偏差排名",
|
||||
description: "按相对调度下限的压力偏差排序",
|
||||
span: 5,
|
||||
chartType: "bar",
|
||||
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
|
||||
unit: "%",
|
||||
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
|
||||
},
|
||||
{
|
||||
id: "pressure-evidence",
|
||||
kind: "data-table",
|
||||
title: "证据链",
|
||||
description: "对诊断结论贡献最高的观测证据",
|
||||
span: 12,
|
||||
columns: [
|
||||
{ key: "time", label: "时间" },
|
||||
{ key: "source", label: "数据源" },
|
||||
{ key: "finding", label: "观测" },
|
||||
{ key: "impact", label: "影响" },
|
||||
{ key: "confidence", label: "置信度", numeric: true }
|
||||
],
|
||||
rows: [
|
||||
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
|
||||
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
|
||||
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
|
||||
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
|
||||
actions: [
|
||||
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
|
||||
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "metric_analysis",
|
||||
lifecycle: "draft",
|
||||
revision: 2,
|
||||
title: "供水服务分区平衡分析",
|
||||
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
|
||||
scope: "六个服务分区 · 今日峰值预测",
|
||||
confidence: "88.4%",
|
||||
generatedAt,
|
||||
analyticalTimeRange: {
|
||||
start: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(0, 0, 0, 0)).toISOString(),
|
||||
end: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(23, 59, 59, 999)).toISOString()
|
||||
},
|
||||
preferredView: "focus",
|
||||
mapRelation: "none",
|
||||
blocks: [
|
||||
{
|
||||
id: "zone-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "分区态势",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
|
||||
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
|
||||
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
|
||||
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-balance",
|
||||
kind: "chart",
|
||||
title: "分区供需对比",
|
||||
description: "北辰与东丽当前需求已接近可用供水能力",
|
||||
span: 7,
|
||||
chartType: "bar",
|
||||
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
|
||||
unit: "万m³/d",
|
||||
series: [
|
||||
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
|
||||
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-risk",
|
||||
kind: "data-table",
|
||||
title: "调配优先级",
|
||||
description: "按负荷率、连续供水和通道可用性综合排序",
|
||||
span: 5,
|
||||
columns: [
|
||||
{ key: "zone", label: "分区" },
|
||||
{ key: "load", label: "负荷率", numeric: true },
|
||||
{ key: "risk", label: "风险" },
|
||||
{ key: "action", label: "建议" }
|
||||
],
|
||||
rows: [
|
||||
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
|
||||
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
|
||||
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
|
||||
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-note",
|
||||
kind: "narrative",
|
||||
title: "Agent 结论",
|
||||
span: 12,
|
||||
paragraphs: [
|
||||
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
|
||||
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
|
||||
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
|
||||
actions: [
|
||||
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
|
||||
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
type: "simulation",
|
||||
lifecycle: "published",
|
||||
revision: 5,
|
||||
title: "低压事件调度处置流程",
|
||||
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
|
||||
scope: "北辰低压事件 · 30 分钟模拟",
|
||||
confidence: "90.8%",
|
||||
generatedAt,
|
||||
analyticalTimeRange: {
|
||||
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
|
||||
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
|
||||
},
|
||||
preferredView: "map_split",
|
||||
mapRelation: "required",
|
||||
blocks: [
|
||||
{
|
||||
id: "flow-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "执行状态",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
|
||||
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
|
||||
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
|
||||
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-process",
|
||||
kind: "process-flow",
|
||||
title: "处置路径",
|
||||
description: "所有调度动作均需在执行前完成权限和现场状态确认",
|
||||
span: 7,
|
||||
nodes: [
|
||||
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
|
||||
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
|
||||
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
|
||||
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
|
||||
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-checklist",
|
||||
kind: "data-table",
|
||||
title: "执行清单",
|
||||
description: "当前阶段需要完成的人员与系统确认",
|
||||
span: 5,
|
||||
columns: [
|
||||
{ key: "owner", label: "责任方" },
|
||||
{ key: "item", label: "确认项" },
|
||||
{ key: "state", label: "状态" }
|
||||
],
|
||||
rows: [
|
||||
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
|
||||
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
|
||||
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
|
||||
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-progress",
|
||||
kind: "chart",
|
||||
title: "处置前后压力预测",
|
||||
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
|
||||
span: 12,
|
||||
chartType: "line",
|
||||
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
|
||||
unit: "MPa",
|
||||
series: [
|
||||
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
|
||||
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
|
||||
]
|
||||
}
|
||||
],
|
||||
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
|
||||
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
|
||||
actions: [
|
||||
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
|
||||
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
|
||||
]
|
||||
});
|
||||
|
||||
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
|
||||
|
||||
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
|
||||
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||
const now = new Date();
|
||||
return factory(
|
||||
`analysis-${now.getTime().toString(36)}-${instance}`,
|
||||
now.toLocaleTimeString("zh-CN", { hour12: false })
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFixtureTimestamp(clock: string) {
|
||||
const [hours = 0, minutes = 0, seconds = 0] = clock.split(":").map(Number);
|
||||
const value = new Date();
|
||||
value.setHours(hours, minutes, seconds, 0);
|
||||
return value.getTime();
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Database,
|
||||
FileSearch,
|
||||
Minimize2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_ICON_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
|
||||
} from "./artifact-view-control-styles";
|
||||
import type {
|
||||
AnalysisArtifact,
|
||||
AnalysisArtifactAction,
|
||||
AnalysisBlock,
|
||||
AnalysisMetric
|
||||
} from "./workspace-model";
|
||||
|
||||
type AnalysisArtifactWorkspaceProps = {
|
||||
artifact: AnalysisArtifact;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
};
|
||||
|
||||
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
|
||||
export function AnalysisArtifactWorkspace({
|
||||
artifact,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: AnalysisArtifactWorkspaceProps) {
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||||
const evidenceBlocks = artifact.blocks.filter(
|
||||
(block): block is EvidenceBlock => block.kind !== "metric-grid"
|
||||
);
|
||||
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBlockId(null);
|
||||
}, [artifact.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBlock) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSelectedBlockId(null);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedBlock]);
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-label={`${artifact.title}分析成果`}
|
||||
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<ArtifactHeader artifact={artifact} />
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||||
<section aria-labelledby="artifact-summary-heading">
|
||||
<div className="mb-3 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
|
||||
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950">结论摘要</h3>
|
||||
</div>
|
||||
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
|
||||
</div>
|
||||
{metricBlock?.kind === "metric-grid" ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
|
||||
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
|
||||
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950">证据画布</h3>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} 个证据模块</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-3.5">
|
||||
{evidenceBlocks.map((block) => (
|
||||
<AnalysisBlockView
|
||||
key={block.id}
|
||||
block={block}
|
||||
selected={block.id === selectedBlockId}
|
||||
onSelect={() => setSelectedBlockId(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ArtifactProvenance artifact={artifact} />
|
||||
</div>
|
||||
|
||||
<ArtifactActionBar
|
||||
artifact={artifact}
|
||||
onCollapse={onCollapse}
|
||||
onDestroy={onDestroy}
|
||||
/>
|
||||
|
||||
{selectedBlock ? (
|
||||
<ArtifactDetailDrawer
|
||||
artifact={artifact}
|
||||
block={selectedBlock}
|
||||
onClose={() => setSelectedBlockId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Agent Artifact
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{artifactTypeLabel(artifact.type)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>R{artifact.revision}</span>
|
||||
</div>
|
||||
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
|
||||
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<LifecycleBadge lifecycle={artifact.lifecycle} />
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock3 size={13} aria-hidden="true" />
|
||||
{artifact.generatedAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||||
<span><strong className="font-medium text-slate-700">范围</strong> {artifact.scope}</span>
|
||||
<span><strong className="font-medium text-slate-700">置信度</strong> {artifact.confidence}</span>
|
||||
<span><strong className="font-medium text-slate-700">地图关系</strong> {mapRelationLabel(artifact.mapRelation)}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisBlockView({
|
||||
block,
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
block: EvidenceBlock;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const spanClass = block.span === 5
|
||||
? "col-span-12 2xl:col-span-5"
|
||||
: block.span === 7
|
||||
? "col-span-12 2xl:col-span-7"
|
||||
: "col-span-12";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`${block.id}-title`}
|
||||
className={cn(
|
||||
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
|
||||
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
|
||||
spanClass,
|
||||
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
|
||||
aria-label={`查看${block.title}详情`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
||||
<FileSearch size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
|
||||
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
|
||||
</span>
|
||||
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="border-t border-slate-100 p-4">
|
||||
<AnalysisBlockBody block={block} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
|
||||
if (block.kind === "chart") {
|
||||
return (
|
||||
<div className="h-[258px] min-h-0">
|
||||
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === "process-flow") {
|
||||
return (
|
||||
<ol className="space-y-0" aria-label={block.title}>
|
||||
{block.nodes.map((node, index) => (
|
||||
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||||
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
|
||||
<span className={cn(
|
||||
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||||
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||||
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||||
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||||
)}>
|
||||
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||||
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === "data-table") {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||||
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, index) => (
|
||||
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||||
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
|
||||
}
|
||||
|
||||
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
|
||||
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
|
||||
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
|
||||
<Database size={14} aria-hidden="true" />
|
||||
数据来源
|
||||
</h3>
|
||||
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
|
||||
{artifact.sources.map((source) => (
|
||||
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
|
||||
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
分析限制
|
||||
</h3>
|
||||
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
|
||||
{artifact.limitations.map((item) => (
|
||||
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactDetailDrawer({
|
||||
artifact,
|
||||
block,
|
||||
onClose
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
block: EvidenceBlock;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const blockFacts = getEvidenceBlockFacts(block);
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="证据详情"
|
||||
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
|
||||
<span className="uppercase tracking-[0.12em]">证据详情</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭证据详情"
|
||||
onClick={onClose}
|
||||
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<section aria-labelledby="evidence-purpose-heading">
|
||||
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500">证据说明</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{evidenceBlockDescription(block)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500">数据概览</h4>
|
||||
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
|
||||
</div>
|
||||
<dl className="mt-2 divide-y divide-slate-200">
|
||||
{blockFacts.map((fact) => (
|
||||
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
|
||||
<dt className="text-slate-500">{fact.label}</dt>
|
||||
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
|
||||
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500">分析上下文</h4>
|
||||
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
|
||||
<dt className="text-slate-500">分析时间</dt>
|
||||
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
|
||||
<dt className="text-slate-500">分析范围</dt>
|
||||
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
|
||||
<dt className="text-slate-500">综合置信度</dt>
|
||||
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function evidenceBlockDescription(block: EvidenceBlock) {
|
||||
return "description" in block
|
||||
? block.description
|
||||
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
|
||||
}
|
||||
|
||||
function evidenceBlockKindLabel(block: EvidenceBlock) {
|
||||
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
|
||||
if (block.kind === "process-flow") return "流程证据";
|
||||
if (block.kind === "data-table") return "明细数据";
|
||||
return "分析说明";
|
||||
}
|
||||
|
||||
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
|
||||
if (block.kind === "chart") {
|
||||
return [
|
||||
{ label: "数据序列", value: `${block.series.length} 组` },
|
||||
{ label: "时间 / 类别点", value: `${block.categories.length} 个` },
|
||||
{ label: "计量单位", value: block.unit }
|
||||
];
|
||||
}
|
||||
if (block.kind === "data-table") {
|
||||
return [
|
||||
{ label: "证据记录", value: `${block.rows.length} 条` },
|
||||
{ label: "数据字段", value: `${block.columns.length} 个` }
|
||||
];
|
||||
}
|
||||
if (block.kind === "process-flow") {
|
||||
return [
|
||||
{ label: "流程步骤", value: `${block.nodes.length} 项` },
|
||||
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length} 项` },
|
||||
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length} 项` }
|
||||
];
|
||||
}
|
||||
return [{ label: "分析段落", value: `${block.paragraphs.length} 段` }];
|
||||
}
|
||||
|
||||
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
|
||||
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
return `${formatter.format(new Date(range.start))} 至 ${formatter.format(new Date(range.end))}`;
|
||||
}
|
||||
|
||||
function ArtifactActionBar({
|
||||
artifact,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||
<span />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||||
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
|
||||
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}:${action.description}` })}
|
||||
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
|
||||
>
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||
return (
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||||
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
|
||||
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
|
||||
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
|
||||
}
|
||||
|
||||
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
|
||||
if (type === "diagnosis") return "异常诊断";
|
||||
if (type === "simulation") return "方案模拟";
|
||||
return "指标分析";
|
||||
}
|
||||
|
||||
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
|
||||
if (relation === "required") return "强关联";
|
||||
if (relation === "optional") return "可选";
|
||||
return "无空间依赖";
|
||||
}
|
||||
|
||||
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||
return {
|
||||
animationDuration: 260,
|
||||
animationEasing: "cubicOut",
|
||||
color: block.series.map((series) => series.color),
|
||||
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||||
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||||
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||||
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||
series: block.series.map((series) => ({
|
||||
name: series.name,
|
||||
type: series.type ?? block.chartType,
|
||||
data: series.values,
|
||||
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
barMaxWidth: 24,
|
||||
lineStyle: { width: 2.5 },
|
||||
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||||
if (tone === "danger") return "bg-rose-500";
|
||||
if (tone === "warning") return "bg-amber-500";
|
||||
if (tone === "success") return "bg-emerald-500";
|
||||
if (tone === "info") return "bg-blue-600";
|
||||
return "bg-slate-400";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
|
||||
} from "./artifact-view-control-styles";
|
||||
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
|
||||
|
||||
export function ArtifactPeekBar({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
onOpen,
|
||||
onDestroy
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
onOpen: () => void;
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Agent 成果预览"
|
||||
className={cn(
|
||||
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
|
||||
"left-1/2 -translate-x-1/2",
|
||||
surfaceMode === "map_split" && "2xl:left-[25%]"
|
||||
)}
|
||||
>
|
||||
<span className="material-tone-agent hidden h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700 sm:grid">
|
||||
<FileChartColumnIncreasing size={21} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
|
||||
<Sparkles size={12} aria-hidden="true" />
|
||||
新分析成果 · R{artifact.revision}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
|
||||
>
|
||||
<ChevronUp size={14} aria-hidden="true" />
|
||||
打开成果
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="销毁预览成果"
|
||||
title="销毁预览成果"
|
||||
onClick={onDestroy}
|
||||
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
|
||||
>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
|
||||
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
|
||||
|
||||
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
|
||||
|
||||
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
|
||||
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
|
||||
|
||||
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
|
||||
|
||||
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
collapseActiveArtifact,
|
||||
createInitialArtifactWorkspaceState,
|
||||
destroyActiveArtifact,
|
||||
destroyPendingArtifact,
|
||||
openPendingArtifact,
|
||||
previewAnalysisArtifact,
|
||||
requestArtifactView,
|
||||
resolveWorkbenchSurfaceMode,
|
||||
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||
type ArtifactRequestedView
|
||||
} from "./workspace-model";
|
||||
|
||||
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
|
||||
const [state, setState] = useState(createInitialArtifactWorkspaceState);
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
|
||||
const createTestArtifactPreview = useCallback(() => {
|
||||
const artifact = createAnalysisArtifactFixture(
|
||||
fixtureIndexRef.current,
|
||||
fixtureInstanceRef.current
|
||||
);
|
||||
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
|
||||
fixtureInstanceRef.current += 1;
|
||||
setState((current) => previewAnalysisArtifact(current, artifact));
|
||||
}, []);
|
||||
|
||||
const openPreview = useCallback(() => {
|
||||
setState(openPendingArtifact);
|
||||
}, []);
|
||||
|
||||
const collapseArtifact = useCallback(() => {
|
||||
setState(collapseActiveArtifact);
|
||||
}, []);
|
||||
|
||||
const destroyPreview = useCallback(() => {
|
||||
setState(destroyPendingArtifact);
|
||||
}, []);
|
||||
|
||||
const destroyArtifact = useCallback(() => {
|
||||
setState(destroyActiveArtifact);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((view: ArtifactRequestedView) => {
|
||||
setState((current) => requestArtifactView(current, view));
|
||||
}, []);
|
||||
|
||||
const surfaceMode = useMemo(
|
||||
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
|
||||
[businessWorkspaceWidth, state]
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
surfaceMode,
|
||||
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
|
||||
createTestArtifactPreview,
|
||||
openPreview,
|
||||
collapseArtifact,
|
||||
destroyPreview,
|
||||
destroyArtifact,
|
||||
setView
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import {
|
||||
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
} from "./artifact-view-control-styles";
|
||||
import type {
|
||||
AnalysisArtifact,
|
||||
ArtifactRequestedView,
|
||||
WorkbenchSurfaceMode
|
||||
} from "./workspace-model";
|
||||
|
||||
type WorkbenchMainFrameProps = {
|
||||
mapContent: ReactNode;
|
||||
mapOverlay?: ReactNode;
|
||||
timelineContent: ReactNode;
|
||||
timelineMode: "compact" | "expanded" | "summary";
|
||||
timelineBottom: number;
|
||||
activeArtifact: AnalysisArtifact | null;
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
onCollapseArtifact: () => void;
|
||||
onDestroyArtifact: () => void;
|
||||
};
|
||||
|
||||
export function WorkbenchMainFrame({
|
||||
mapContent,
|
||||
mapOverlay,
|
||||
timelineContent,
|
||||
timelineMode,
|
||||
timelineBottom,
|
||||
activeArtifact,
|
||||
pendingArtifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
onCollapseArtifact,
|
||||
onDestroyArtifact
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(
|
||||
activeArtifact &&
|
||||
activeArtifact.mapRelation !== "none"
|
||||
);
|
||||
const mapOnly = surfaceMode === "map_only";
|
||||
const mapSplit = surfaceMode === "map_split";
|
||||
const viewControlLabel = mapOnly
|
||||
? "返回分析成果"
|
||||
: mapSplit
|
||||
? "专注分析"
|
||||
: "显示地图";
|
||||
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
|
||||
const timelineTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const viewControlTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const artifactTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
|
||||
data-testid="workbench-main-frame"
|
||||
data-timeline-mode={timelineMode}
|
||||
>
|
||||
<div
|
||||
id="main-workspace"
|
||||
data-layout-mode={surfaceMode}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||
surfaceMode !== "map_split" && "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</section>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn(
|
||||
"z-30 min-h-0 min-w-0 overflow-hidden",
|
||||
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={showArtifact
|
||||
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.994,
|
||||
transitionEnd: { visibility: "hidden" }
|
||||
}}
|
||||
exit={prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 10, scale: 0.992 }}
|
||||
transition={artifactTransition}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{pendingArtifact ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
onOpen={onOpenPendingArtifact}
|
||||
onDestroy={onDestroyPendingArtifact}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showArtifactViewControl ? (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
)}
|
||||
initial={prefersReducedMotion
|
||||
? false
|
||||
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
||||
transition={viewControlTransition}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={viewControlLabel}
|
||||
onClick={() => onSetArtifactView(
|
||||
surfaceMode === "focus"
|
||||
? mapSplitAvailable ? "map_split" : "map_only"
|
||||
: "focus"
|
||||
)}
|
||||
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={surfaceMode}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
|
||||
transition={viewControlTransition}
|
||||
>
|
||||
{mapOnly ? (
|
||||
<ArrowLeft size={13} aria-hidden="true" />
|
||||
) : mapSplit ? (
|
||||
<Focus size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<MapIcon size={13} aria-hidden="true" />
|
||||
)}
|
||||
{viewControlLabel}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
|
||||
initial={false}
|
||||
animate={{
|
||||
bottom: timelineBottom,
|
||||
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
||||
right: 12
|
||||
}}
|
||||
transition={timelineTransition}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{timelineContent ? (
|
||||
<motion.div
|
||||
key="operational-timeline"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
|
||||
transition={timelineTransition}
|
||||
>
|
||||
{timelineContent}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
|
||||
import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace-context";
|
||||
|
||||
describe("workspace context", () => {
|
||||
it("keeps operational time independent from artifact analytical time", () => {
|
||||
const artifact = createAnalysisArtifactFixture(0, 0);
|
||||
const withArtifact = reduceWorkspaceContext(createInitialWorkspaceContext(), {
|
||||
type: "sync-artifact",
|
||||
artifact
|
||||
});
|
||||
const selectedEvent = reduceWorkspaceContext(withArtifact, {
|
||||
type: "select-operational-event",
|
||||
eventId: "event-1",
|
||||
cursorTime: "2026-08-19T10:30:00.000Z"
|
||||
});
|
||||
|
||||
expect(selectedEvent.analytical.timeRange).toEqual(artifact.analyticalTimeRange);
|
||||
expect(selectedEvent.operational.cursorTime).toBe("2026-08-19T10:30:00.000Z");
|
||||
});
|
||||
|
||||
it("updates analytical context without moving the operational cursor", () => {
|
||||
const initial = createInitialWorkspaceContext(new Date("2026-08-19T08:00:00.000Z"));
|
||||
const cursorTime = initial.operational.cursorTime;
|
||||
const withArtifact = reduceWorkspaceContext(initial, {
|
||||
type: "sync-artifact",
|
||||
artifact: createAnalysisArtifactFixture(1, 1)
|
||||
});
|
||||
|
||||
expect(withArtifact.operational.cursorTime).toBe(cursorTime);
|
||||
expect(withArtifact.analytical.artifactId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears selection while retaining cursor until returning to now", () => {
|
||||
const initial = createInitialWorkspaceContext();
|
||||
const selected = reduceWorkspaceContext(initial, {
|
||||
type: "select-operational-event",
|
||||
eventId: "event-2",
|
||||
cursorTime: "2026-08-19T13:10:00.000Z"
|
||||
});
|
||||
const cleared = reduceWorkspaceContext(selected, { type: "clear-operational-event" });
|
||||
|
||||
expect(cleared.operational.selectedEventId).toBeNull();
|
||||
expect(cleared.operational.cursorTime).toBe("2026-08-19T13:10:00.000Z");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user