feat: initialize TJWater WebGIS frontend
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||||
|
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
||||||
|
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||||
|
TJWATER_ENABLE_DEV_PANEL=false
|
||||||
|
TJWATER_ENABLE_MSW=false
|
||||||
|
|
||||||
|
# Optional development proxy target; never exposed to browser code.
|
||||||
|
AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*:Zone.Identifier
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This is a Vite React single-page WebGIS Agent workbench. Runtime TypeScript lives
|
||||||
|
under `src/`.
|
||||||
|
|
||||||
|
- `src/app/`: application shell and providers.
|
||||||
|
- `src/features/agent/`: controlled Agent UI protocol, schemas, renderer, and views.
|
||||||
|
- `src/features/map/`: WebGIS map shell and future MapLibre/deck.gl integrations.
|
||||||
|
- `src/shared/`: reusable UI, config, styles, and utilities.
|
||||||
|
- `src/mocks/`: MSW handlers and fixtures for backend-independent development.
|
||||||
|
- `src/test/`: Vitest setup.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Use `pnpm`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm test:browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Notes
|
||||||
|
|
||||||
|
Use TypeScript and React function components. Prefer explicit exported types at
|
||||||
|
module boundaries. Keep Agent dynamic UI rendering schema-driven: Agent output
|
||||||
|
must be validated before it reaches React components.
|
||||||
|
|
||||||
|
Do not add Next.js APIs or server routes to this frontend. Backend/BFF behavior
|
||||||
|
belongs in the Agent service or a separate service.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
:80 {
|
||||||
|
root * /srv
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@runtimeConfig path /runtime-config.js
|
||||||
|
header @runtimeConfig Cache-Control "no-store"
|
||||||
|
|
||||||
|
@immutableAssets path /assets/*
|
||||||
|
header @immutableAssets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM caddy:2.10.2-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache jq
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
|
||||||
|
COPY --from=build /app/dist /srv
|
||||||
|
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/usr/local/bin/tjwater-frontend-entrypoint"]
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
runtime_config=$(jq -cn \
|
||||||
|
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||||
|
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||||
|
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
||||||
|
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
|
||||||
|
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||||
|
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||||
|
'{
|
||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||||
|
TJWATER_MAP_URL: $mapUrl,
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||||
|
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
||||||
|
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
||||||
|
TJWATER_ENABLE_MSW: $enableMsw
|
||||||
|
}')
|
||||||
|
|
||||||
|
printf 'globalThis.__TJWATER_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Rive Persona 内存问题排查记录
|
||||||
|
|
||||||
|
日期:2026-07-21
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`next-webgis` 中的 Obsidian Persona 使用 Rive WebGL2 渲染。迁移到
|
||||||
|
`next-tjwater-frontend` 的 Vite、React 运行环境后,页面加载 Persona 时出现浏览器
|
||||||
|
`Out of Memory`,同时需要保留原有羽化灰黑球体的视觉效果。
|
||||||
|
|
||||||
|
本次排障验证使用 `@rive-app/react-webgl2@4.29.3`,依赖声明保留 4.x 兼容版本范围。
|
||||||
|
资源仍使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已证实的触发条件
|
||||||
|
|
||||||
|
在同一 Chromium 环境下进行了对照测试。以下内存值是排障时的单次观测范围,
|
||||||
|
用于判断增长趋势,不应视为跨设备性能基准。
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
| ------------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| Vite、WebGL2、绑定 Rive View Model | 渲染进程快速增长到约 4.6 GB,随后失去响应或崩溃 |
|
||||||
|
| Vite、阻止 `.riv` 加载 | 约 29 MB 至 61 MB,保持稳定 |
|
||||||
|
| Vite、Canvas、不绑定 View Model | 内存稳定,但渲染边缘和质感不符合原设计 |
|
||||||
|
| Vite、Canvas、调用 `bindViewModelInstance` | 再次出现持续增长 |
|
||||||
|
| Vite、WebGL2 4.29.3、调用 `bindViewModelInstance` | 再次出现持续增长 |
|
||||||
|
| Vite、WebGL2 4.29.3、不绑定 View Model | 约 71 MB 至 116 MB,最终约 78 MB |
|
||||||
|
| `next-webgis` 原实现 | 约 165 MB 至 221 MB,未出现持续增长 |
|
||||||
|
|
||||||
|
可以确认:在迁移后的 Vite 应用中,`bindViewModelInstance` 是泄漏的稳定触发点。
|
||||||
|
`useViewModelInstance` 内部也会执行该绑定,因此仅替换颜色 hook 不能解决问题。
|
||||||
|
|
||||||
|
不能仅凭现有证据断言缺陷完全属于 Vite、React 或 Rive 某一方。`next-webgis` 的同一
|
||||||
|
资源和完整绑定能够稳定运行,说明这是新运行环境与 Rive View Model 绑定生命周期的
|
||||||
|
兼容问题。若要定位到 Rive 内部对象,需要进一步制作最小复现并进行运行时级堆分析。
|
||||||
|
|
||||||
|
## 为什么不是 TypeScript 7
|
||||||
|
|
||||||
|
TypeScript 在该项目中只通过 `tsc --noEmit` 做静态检查。浏览器实际执行的是 Vite 和
|
||||||
|
SWC 转换后的 JavaScript。泄漏可以通过是否调用 Rive View Model 绑定稳定开关,因此
|
||||||
|
与 TypeScript 类型检查器无关。
|
||||||
|
|
||||||
|
## 最终修改
|
||||||
|
|
||||||
|
1. 保留 Rive WebGL2、Obsidian 资源和 `default` 状态机。
|
||||||
|
2. 移除 `useViewModel`、`useViewModelInstance`、`useViewModelInstanceColor` 及所有
|
||||||
|
`bindViewModelInstance` 调用。
|
||||||
|
3. 使用 CSS `brightness` 和 `contrast` 保持浅色界面中的灰黑视觉,不再监听并不存在的
|
||||||
|
应用深色主题。
|
||||||
|
4. Persona 仅在元素进入视口且页面可见时挂载,避免桌面和移动响应式节点同时初始化。
|
||||||
|
5. 首次初始化延迟到下一帧,使 React Strict Mode 的探测挂载可以在创建 Rive 实例前取消。
|
||||||
|
6. 保留懒加载和加载失败占位,但移除实验阶段的多皮肤、重复状态推导和无用事件透传。
|
||||||
|
7. 移除 Persona 环境变量开关,动画由组件生命周期直接管理。
|
||||||
|
|
||||||
|
## 回归保护
|
||||||
|
|
||||||
|
- `src/shared/ai-elements/persona.test.tsx` 验证加载的是 Obsidian 和 `default` 状态机,
|
||||||
|
同时保证不会调用 `bindViewModelInstance`。
|
||||||
|
- `src/app/app.e2e.ts` 验证页面只发出一次 `.riv` 请求。
|
||||||
|
- 桌面 1440 x 900 和移动端 375 x 812 均验证无横向溢出。
|
||||||
|
|
||||||
|
验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm test:browser
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续修改约束
|
||||||
|
|
||||||
|
- 在独立最小复现和持续内存采样通过前,不要重新引入 Rive View Model 绑定。
|
||||||
|
- 不要用 CSS 隐藏两个已挂载的 Persona,响应式副本必须通过可见性挂载保持单实例。
|
||||||
|
- 不要移除 Strict Mode 延迟初始化,除非重新验证开发和生产生命周期。
|
||||||
|
- 更新 Rive 版本时,至少验证单次 `.riv` 请求、状态机切换、页面切换后的实例清理和
|
||||||
|
30 秒以上的浏览器进程内存趋势。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import babelParser from "@babel/eslint-parser";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**", "playwright-report/**", "test-results/**"]
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
globals: globals.browser,
|
||||||
|
parser: babelParser,
|
||||||
|
parserOptions: {
|
||||||
|
requireConfigFile: false,
|
||||||
|
babelOptions: {
|
||||||
|
presets: ["@babel/preset-typescript", ["@babel/preset-react", { runtime: "automatic" }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TJWater WebGIS Agent</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/runtime-config.js"></script>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "tjwater-webgis-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:browser": "playwright test"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.14",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.14",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.17",
|
||||||
|
"@radix-ui/react-select": "^2.3.1",
|
||||||
|
"@radix-ui/react-separator": "^1.1.10",
|
||||||
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.10",
|
||||||
|
"@rive-app/react-webgl2": "^4.29.3",
|
||||||
|
"@streamdown/cjk": "^1.0.3",
|
||||||
|
"@streamdown/code": "^1.1.1",
|
||||||
|
"@streamdown/math": "^1.0.2",
|
||||||
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"ai": "^7.0.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"katex": "^0.17.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"maplibre-gl": "^4.7.1",
|
||||||
|
"motion": "^12.40.0",
|
||||||
|
"nanoid": "^5.1.14",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"shiki": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"streamdown": "^2.5.0",
|
||||||
|
"swr": "^2.4.2",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.25.9",
|
||||||
|
"@babel/preset-react": "^7.25.9",
|
||||||
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/node": "^24.7.2",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"msw": "^2.6.8",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"typescript": "^7.0.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./src",
|
||||||
|
testMatch: ["**/*.e2e.ts"],
|
||||||
|
fullyParallel: true,
|
||||||
|
reporter: "html",
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5173",
|
||||||
|
trace: "on-first-retry"
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://127.0.0.1:5173",
|
||||||
|
reuseExistingServer: true
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
Generated
+8355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
packages:
|
||||||
|
- .
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
'@swc/core': true
|
||||||
|
esbuild: true
|
||||||
|
msw: true
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@swc/core"
|
||||||
|
- esbuild
|
||||||
|
- msw
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 2,
|
||||||
|
semi: true,
|
||||||
|
trailingComma: "none"
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
globalThis.__TJWATER_CONFIG__ = {};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
||||||
|
const riveRequests: string[] = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (/\.riv(?:\?|$)/.test(request.url())) {
|
||||||
|
riveRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
|
||||||
|
await expect(page.getByText("调度工作台")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("推荐问题")).toBeVisible();
|
||||||
|
|
||||||
|
const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__);
|
||||||
|
expect(runtimeConfig).toMatchObject({
|
||||||
|
TJWATER_MAP_URL: expect.any(String),
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
|
||||||
|
TJWATER_AGENT_API_BASE_URL: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||||
|
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapAsset = await page.request.get("/map/valve.png");
|
||||||
|
expect(mapAsset.ok()).toBe(true);
|
||||||
|
expect(mapAsset.headers()["content-type"]).toBe("image/png");
|
||||||
|
await expect(page.locator('[aria-label="Agent 命令面板"] canvas').first()).toBeVisible({
|
||||||
|
timeout: 25_000
|
||||||
|
});
|
||||||
|
await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { MapToaster } from "@/features/map/core/components/notice";
|
||||||
|
import { MapWorkbenchPage } from "@/features/workbench";
|
||||||
|
import { AppProviders } from "./providers";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<MapWorkbenchPage />
|
||||||
|
<MapToaster />
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { SWRConfig } from "swr";
|
||||||
|
|
||||||
|
async function fetcher<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fetcher,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</SWRConfig>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createAgentApiClient } from "./client";
|
||||||
|
|
||||||
|
describe("Agent API client sessions", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists normalized sessions in newest-first order", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sessions: [
|
||||||
|
{ id: "old", title: "", created_at: 100, updated_at: 120 },
|
||||||
|
{ id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions = await createAgentApiClient("http://agent.local").listSessions();
|
||||||
|
|
||||||
|
expect(sessions).toEqual([
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
title: "新会话",
|
||||||
|
createdAt: 200,
|
||||||
|
updatedAt: 210,
|
||||||
|
status: undefined,
|
||||||
|
parentSessionId: undefined,
|
||||||
|
isStreaming: undefined,
|
||||||
|
runStatus: "running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old",
|
||||||
|
title: "新对话",
|
||||||
|
createdAt: 100,
|
||||||
|
updatedAt: 120,
|
||||||
|
status: undefined,
|
||||||
|
parentSessionId: undefined,
|
||||||
|
isStreaming: undefined,
|
||||||
|
runStatus: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when loading a missing session", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 })));
|
||||||
|
|
||||||
|
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when a candidate is not the agent service", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: [],
|
||||||
|
components: [],
|
||||||
|
actions: []
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: [],
|
||||||
|
components: [],
|
||||||
|
actions: []
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"http://not-agent.local/api/v1/agent/chat/ui-registry",
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends title update payloads using backend field names", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", {
|
||||||
|
isTitleManuallyEdited: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://agent.local/api/v1/agent/chat/session/session-1/title",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "标题",
|
||||||
|
is_title_manually_edited: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams session events from the backend SSE endpoint", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
[
|
||||||
|
'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n',
|
||||||
|
'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n',
|
||||||
|
'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n'
|
||||||
|
].join(""),
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const events: unknown[] = [];
|
||||||
|
|
||||||
|
await createAgentApiClient("http://agent.local").streamSession("session-1", {
|
||||||
|
onEvent: (event) => events.push(event)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-1",
|
||||||
|
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "token",
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: { session_id: "session-1", content: "处理中" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: { session_id: "session-1", duration_ms: 20 }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
import { env } from "@/shared/config/env";
|
||||||
|
|
||||||
|
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||||
|
|
||||||
|
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
|
export type AgentModelOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: "bolt" | "sparkle";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentModelsResponse = {
|
||||||
|
defaultModel: string;
|
||||||
|
models: AgentModelOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentChatSession = {
|
||||||
|
id?: string;
|
||||||
|
session_id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
run_status?: AgentRunStatus;
|
||||||
|
is_streaming?: boolean;
|
||||||
|
messages?: unknown[];
|
||||||
|
created_at?: number | string;
|
||||||
|
updated_at?: number | string;
|
||||||
|
is_title_manually_edited?: boolean;
|
||||||
|
parent_session_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentChatSessionSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
status?: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
runStatus?: AgentRunStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||||
|
sessionId: string;
|
||||||
|
isTitleManuallyEdited: boolean;
|
||||||
|
messages: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentSessionStreamDataEventType =
|
||||||
|
| "token"
|
||||||
|
| "progress"
|
||||||
|
| "todo_update"
|
||||||
|
| "permission_request"
|
||||||
|
| "permission_response"
|
||||||
|
| "question_request"
|
||||||
|
| "question_response"
|
||||||
|
| "tool_call"
|
||||||
|
| "frontend_action"
|
||||||
|
| "frontend_action_result"
|
||||||
|
| "ui_envelope"
|
||||||
|
| "session_title"
|
||||||
|
| "done"
|
||||||
|
| "error"
|
||||||
|
| "auth_required";
|
||||||
|
|
||||||
|
export type AgentSessionStreamEvent =
|
||||||
|
| {
|
||||||
|
type: "state";
|
||||||
|
sessionId: string;
|
||||||
|
messages: unknown[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
runStatus?: AgentRunStatus;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: AgentSessionStreamDataEventType;
|
||||||
|
sessionId?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentApiClient = {
|
||||||
|
createSession: () => Promise<AgentChatSession>;
|
||||||
|
listSessions: () => Promise<AgentChatSessionSummary[]>;
|
||||||
|
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||||
|
streamSession: (
|
||||||
|
sessionId: string,
|
||||||
|
options: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||||
|
}
|
||||||
|
) => Promise<void>;
|
||||||
|
updateSessionTitle: (
|
||||||
|
sessionId: string,
|
||||||
|
title: string,
|
||||||
|
options?: { isTitleManuallyEdited?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteSession: (sessionId: string) => Promise<void>;
|
||||||
|
getModels: () => Promise<AgentModelsResponse>;
|
||||||
|
getUiRegistry: () => Promise<unknown>;
|
||||||
|
getFrontendActionRegistry: () => Promise<unknown>;
|
||||||
|
submitFrontendActionResult: (sessionId: string, actionId: string, result: unknown) => Promise<void>;
|
||||||
|
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
|
||||||
|
replyPermission: (
|
||||||
|
requestId: string,
|
||||||
|
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
|
||||||
|
) => Promise<void>;
|
||||||
|
replyQuestion: (
|
||||||
|
requestId: string,
|
||||||
|
options: { sessionId: string; answers: string[][] }
|
||||||
|
) => Promise<void>;
|
||||||
|
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
||||||
|
abort: (sessionId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||||
|
let activeBaseUrl = candidates[0] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
async createSession() {
|
||||||
|
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, "/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSessions() {
|
||||||
|
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
(nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
},
|
||||||
|
"/sessions"
|
||||||
|
);
|
||||||
|
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFrontendActionRegistry() {
|
||||||
|
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitFrontendActionResult(sessionId, actionId, result) {
|
||||||
|
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||||
|
body: JSON.stringify(result)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSession(sessionId) {
|
||||||
|
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getResponseErrorMessage(data, response.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return toLoadedSession(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async streamSession(sessionId, options) {
|
||||||
|
const response = await fetchWithFallback(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
(nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
},
|
||||||
|
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||||
|
{ signal: options.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
throw new Error(getResponseErrorMessage(data, response.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
await readSessionSseStream(response, options.onEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSessionTitle(sessionId, title, options) {
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestJsonWithFallback<unknown>(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
(nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
},
|
||||||
|
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: normalizedTitle,
|
||||||
|
is_title_manually_edited: options?.isTitleManuallyEdited
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSession(sessionId) {
|
||||||
|
await requestJsonWithFallback<unknown>(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
(nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
},
|
||||||
|
`/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getModels() {
|
||||||
|
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, "/models");
|
||||||
|
return toModelsResponse(payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUiRegistry() {
|
||||||
|
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, "/ui-registry");
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveRenderRef(renderRef, sessionId) {
|
||||||
|
const params = new URLSearchParams({ session_id: sessionId });
|
||||||
|
return requestJsonWithFallback<unknown>(
|
||||||
|
candidates,
|
||||||
|
activeBaseUrl,
|
||||||
|
(nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
},
|
||||||
|
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async replyPermission(requestId, options) {
|
||||||
|
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: options.sessionId,
|
||||||
|
reply: options.reply,
|
||||||
|
message: options.message
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async replyQuestion(requestId, options) {
|
||||||
|
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: options.sessionId,
|
||||||
|
answers: options.answers
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async rejectQuestion(requestId, options) {
|
||||||
|
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: options.sessionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async abort(sessionId) {
|
||||||
|
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||||
|
activeBaseUrl = nextBaseUrl;
|
||||||
|
}, "/abort", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: sessionId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||||
|
let lastError: unknown;
|
||||||
|
let lastResponse: Response | null = null;
|
||||||
|
|
||||||
|
for (const baseUrl of orderedBaseUrls) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||||
|
if (response.ok) {
|
||||||
|
setActiveBaseUrl(baseUrl);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResponse = response;
|
||||||
|
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResponse) {
|
||||||
|
return lastResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackOnHttpStatus(status: number) {
|
||||||
|
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPresent<T>(value: T | null | undefined): value is T {
|
||||||
|
return value !== null && value !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toModelsResponse(value: unknown): AgentModelsResponse {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return { defaultModel: "", models: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : [];
|
||||||
|
const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? "";
|
||||||
|
return {
|
||||||
|
defaultModel,
|
||||||
|
models
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toModelOption(value: unknown): AgentModelOption | null {
|
||||||
|
if (!isRecord(value) || typeof value.id !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id;
|
||||||
|
const description =
|
||||||
|
typeof value.description === "string" && value.description.trim() ? value.description : label;
|
||||||
|
const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseErrorMessage(data: unknown, status: number) {
|
||||||
|
return isRecord(data) && typeof data.message === "string"
|
||||||
|
? data.message
|
||||||
|
: `Agent API failed with HTTP ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMillis(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||||
|
}
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : "新对话";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRunStatus(value: unknown): AgentRunStatus | undefined {
|
||||||
|
return value === "running" || value === "completed" || value === "error" || value === "aborted"
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : "";
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: normalizeTitle(value.title),
|
||||||
|
createdAt: toMillis(value.created_at),
|
||||||
|
updatedAt: toMillis(value.updated_at),
|
||||||
|
status: typeof value.status === "string" ? value.status : undefined,
|
||||||
|
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
|
||||||
|
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
|
||||||
|
runStatus: readRunStatus(value.run_status)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoadedSession(value: unknown): AgentLoadedChatSession {
|
||||||
|
const summary = toSessionSummary(value);
|
||||||
|
if (!summary || !isRecord(value)) {
|
||||||
|
throw new Error("Invalid agent session payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
|
||||||
|
messages: Array.isArray(value.messages) ? value.messages : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSessionSseStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (event: AgentSessionStreamEvent) => void
|
||||||
|
) {
|
||||||
|
if (!response.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
|
|
||||||
|
let delimiterIndex = buffer.indexOf("\n\n");
|
||||||
|
while (delimiterIndex !== -1) {
|
||||||
|
const rawEvent = buffer.slice(0, delimiterIndex);
|
||||||
|
buffer = buffer.slice(delimiterIndex + 2);
|
||||||
|
emitSessionSseEvent(rawEvent, onEvent);
|
||||||
|
delimiterIndex = buffer.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
emitSessionSseEvent(buffer, onEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSessionSseEvent(
|
||||||
|
rawEvent: string,
|
||||||
|
onEvent: (event: AgentSessionStreamEvent) => void
|
||||||
|
) {
|
||||||
|
const lines = rawEvent.split(/\r?\n/);
|
||||||
|
const eventType = lines
|
||||||
|
.find((line) => line.startsWith("event:"))
|
||||||
|
?.slice("event:".length)
|
||||||
|
.trim();
|
||||||
|
const dataText = lines
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice("data:".length).trimStart())
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (!eventType || !dataText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(dataText) as unknown;
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "state") {
|
||||||
|
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId,
|
||||||
|
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||||
|
isStreaming: data.is_streaming === true,
|
||||||
|
runStatus: readRunStatus(data.run_status)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSessionStreamDataEventType(eventType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent({
|
||||||
|
type: eventType,
|
||||||
|
sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
||||||
|
return (
|
||||||
|
value === "token" ||
|
||||||
|
value === "progress" ||
|
||||||
|
value === "todo_update" ||
|
||||||
|
value === "permission_request" ||
|
||||||
|
value === "permission_response" ||
|
||||||
|
value === "question_request" ||
|
||||||
|
value === "question_response" ||
|
||||||
|
value === "tool_call" ||
|
||||||
|
value === "frontend_action" ||
|
||||||
|
value === "frontend_action_result" ||
|
||||||
|
value === "ui_envelope" ||
|
||||||
|
value === "session_title" ||
|
||||||
|
value === "done" ||
|
||||||
|
value === "error" ||
|
||||||
|
value === "auth_required"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) {
|
||||||
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
|
if (createdAtDiff !== 0) {
|
||||||
|
return createdAtDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAtDiff = right.updatedAt - left.updatedAt;
|
||||||
|
if (updatedAtDiff !== 0) {
|
||||||
|
return updatedAtDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.id.localeCompare(left.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AgentApiClient } from "./client";
|
||||||
|
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
|
||||||
|
|
||||||
|
describe("Agent API SWR helpers", () => {
|
||||||
|
it("loads bootstrap data with a normalized UI registry", async () => {
|
||||||
|
const client = {
|
||||||
|
getUiRegistry: vi.fn(async () => ({
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: ["echarts-safe-subset"],
|
||||||
|
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }],
|
||||||
|
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||||
|
})),
|
||||||
|
getModels: vi.fn(async () => ({
|
||||||
|
defaultModel: "model-a",
|
||||||
|
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }]
|
||||||
|
}))
|
||||||
|
} as Pick<AgentApiClient, "getUiRegistry" | "getModels"> as AgentApiClient;
|
||||||
|
|
||||||
|
await expect(fetchAgentBootstrap(client)).resolves.toEqual({
|
||||||
|
defaultModel: "model-a",
|
||||||
|
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }],
|
||||||
|
registry: {
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: ["echarts-safe-subset"],
|
||||||
|
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }],
|
||||||
|
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads session summaries through the shared client", async () => {
|
||||||
|
const sessions = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const client = {
|
||||||
|
listSessions: vi.fn(async () => sessions)
|
||||||
|
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
|
||||||
|
|
||||||
|
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type {
|
||||||
|
AgentApiClient,
|
||||||
|
AgentChatSessionSummary,
|
||||||
|
AgentModelOption
|
||||||
|
} from "./client";
|
||||||
|
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
||||||
|
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
|
||||||
|
|
||||||
|
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
|
||||||
|
export const agentSessionsKey = ["agent", "sessions"] as const;
|
||||||
|
|
||||||
|
export type AgentBootstrapData = {
|
||||||
|
registry: UIRegistry | null;
|
||||||
|
frontendActionRegistry?: FrontendActionRegistry | null;
|
||||||
|
models: AgentModelOption[];
|
||||||
|
defaultModel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
||||||
|
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
|
||||||
|
client.getUiRegistry(),
|
||||||
|
typeof client.getFrontendActionRegistry === "function" ? client.getFrontendActionRegistry() : Promise.resolve(undefined),
|
||||||
|
client.getModels()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registry: parseUiRegistry(rawRegistry),
|
||||||
|
...(rawFrontendActionRegistry === undefined ? {} : { frontendActionRegistry: parseFrontendActionRegistry(rawFrontendActionRegistry) }),
|
||||||
|
models: modelsResponse.models,
|
||||||
|
defaultModel: modelsResponse.defaultModel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
|
||||||
|
return client.listSessions();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||||
|
|
||||||
|
describe("chart data normalization", () => {
|
||||||
|
it("parses chart spec aliases", () => {
|
||||||
|
expect(parseChartSpec({ title: "压力", chart_type: "bar", x_axis_name: "时间" })).toEqual({
|
||||||
|
title: "压力",
|
||||||
|
chartType: "bar",
|
||||||
|
xAxisName: "时间",
|
||||||
|
yAxisName: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes series with x_data and numeric strings", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSafeChartData({
|
||||||
|
x_data: ["00:00", "01:00"],
|
||||||
|
series: [{ name: "压力", data: ["1.2", 1.4] }]
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
xData: ["00:00", "01:00"],
|
||||||
|
series: [{ name: "压力", data: [1.2, 1.4], type: undefined }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes raw point arrays into a default series", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSafeChartData({
|
||||||
|
series: [
|
||||||
|
["A", 12],
|
||||||
|
["B", "13"]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "数据", data: [12, 13], type: undefined }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes object points", () => {
|
||||||
|
expect(pointToLabelValue({ time: "10:00", value: "2.4" }, "fallback")).toEqual({
|
||||||
|
label: "10:00",
|
||||||
|
value: 2.4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips chart points to the safe limit", () => {
|
||||||
|
const data = normalizeSafeChartData(
|
||||||
|
{
|
||||||
|
x_data: Array.from({ length: 30 }, (_, index) => String(index)),
|
||||||
|
series: [{ name: "流量", data: Array.from({ length: 30 }, (_, index) => index) }]
|
||||||
|
},
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(data?.xData).toHaveLength(5);
|
||||||
|
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
export type SafeChartSpec = {
|
||||||
|
title?: string;
|
||||||
|
chartType: "line" | "bar";
|
||||||
|
xAxisName?: string;
|
||||||
|
yAxisName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SafeChartSeries = {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
type?: "line" | "bar";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SafeChartData = {
|
||||||
|
xData: string[];
|
||||||
|
series: SafeChartSeries[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawChartPoint = number | string | [unknown, unknown] | RawChartPointObject;
|
||||||
|
|
||||||
|
type RawChartPointObject = {
|
||||||
|
x?: unknown;
|
||||||
|
y?: unknown;
|
||||||
|
time?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawChartSeries = {
|
||||||
|
name?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
points?: unknown;
|
||||||
|
values?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseChartSpec(value: unknown): SafeChartSpec {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return { chartType: "line" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: stringValue(value.title),
|
||||||
|
chartType: value.chart_type === "bar" || value.chartType === "bar" ? "bar" : "line",
|
||||||
|
xAxisName: stringValue(value.x_axis_name ?? value.xAxisName),
|
||||||
|
yAxisName: stringValue(value.y_axis_name ?? value.yAxisName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChartData | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xData = normalizeXData(value.x_data ?? value.xData);
|
||||||
|
const rawSeriesItems = normalizeRawSeriesItems(value.series);
|
||||||
|
if (!rawSeriesItems.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeries = rawSeriesItems
|
||||||
|
.map((rawItem, seriesIndex): SafeChartSeries | null => {
|
||||||
|
const item =
|
||||||
|
isRecord(rawItem) && !isRawChartPoint(rawItem) ? rawItem : ({ data: rawItem } satisfies RawChartSeries);
|
||||||
|
const rawData = item.data ?? item.points ?? item.values;
|
||||||
|
if (!Array.isArray(rawData)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelsFromPoints: string[] = [];
|
||||||
|
const data = rawData
|
||||||
|
.slice(0, maxPoints)
|
||||||
|
.map((point, index) => {
|
||||||
|
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
labelsFromPoints[index] = parsed.label;
|
||||||
|
return parsed.value;
|
||||||
|
})
|
||||||
|
.filter((item): item is number => item !== null);
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xData.length && labelsFromPoints.length) {
|
||||||
|
xData.push(...labelsFromPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
|
||||||
|
data,
|
||||||
|
type: item.type === "line" || item.type === "bar" ? item.type : undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is SafeChartSeries => Boolean(item));
|
||||||
|
|
||||||
|
const clippedXData = xData.slice(0, maxPoints);
|
||||||
|
const clippedSeries = normalizedSeries.map((series) => ({
|
||||||
|
...series,
|
||||||
|
data: series.data.slice(0, clippedXData.length)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return clippedXData.length > 0 && clippedSeries.length > 0
|
||||||
|
? {
|
||||||
|
xData: clippedXData,
|
||||||
|
series: clippedSeries
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointToLabelValue(point: RawChartPoint, fallbackLabel: string): { label: string; value: number } | null {
|
||||||
|
const directValue = toFiniteNumber(point);
|
||||||
|
if (directValue !== null) {
|
||||||
|
return { label: fallbackLabel, value: directValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(point)) {
|
||||||
|
const value = toFiniteNumber(point[1]);
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { label: String(point[0] ?? fallbackLabel), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(point)) {
|
||||||
|
const value = toFiniteNumber(point.value ?? point.y);
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
||||||
|
return { label: String(label), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeXData(rawXData: unknown): string[] {
|
||||||
|
return Array.isArray(rawXData) ? rawXData.map((item) => String(item ?? "")).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRawSeriesItems(rawSeries: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(rawSeries)) {
|
||||||
|
return isRecord(rawSeries) ? [rawSeries] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) ? [{ name: "数据", data: rawSeries }] : rawSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRawChartPoint(item: unknown): boolean {
|
||||||
|
if (toFiniteNumber(item) !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
||||||
|
}
|
||||||
|
if (isRecord(item)) {
|
||||||
|
return item.data === undefined && item.points === undefined && item.values === undefined && toFiniteNumber(item.value ?? item.y) !== null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { AgentPersona } from "./agent-persona";
|
||||||
|
|
||||||
|
type AgentCollapsedRailProps = {
|
||||||
|
personaState?: PersonaState;
|
||||||
|
onExpand: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentCollapsedRail({ personaState, onExpand }: AgentCollapsedRailProps) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Agent 折叠栏"
|
||||||
|
className={cn(
|
||||||
|
"agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="展开排水助手面板"
|
||||||
|
title="展开排水助手面板"
|
||||||
|
onClick={onExpand}
|
||||||
|
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||||
|
>
|
||||||
|
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||||
|
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition-transform group-hover:translate-x-0.5">
|
||||||
|
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AgentChatSessionSummary } from "../api/client";
|
||||||
|
import {
|
||||||
|
agentLayoutTransition,
|
||||||
|
agentPanelItemVariants,
|
||||||
|
agentPanelListVariants
|
||||||
|
} from "./agent-motion";
|
||||||
|
|
||||||
|
type AgentHistoryPanelProps = {
|
||||||
|
activeSessionId?: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
loadingSessionId: string | null;
|
||||||
|
sessions: AgentChatSessionSummary[];
|
||||||
|
onRefresh?: () => Promise<void> | void;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||||
|
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentHistoryPanel({
|
||||||
|
activeSessionId,
|
||||||
|
loading,
|
||||||
|
loadingSessionId,
|
||||||
|
sessions,
|
||||||
|
onRefresh,
|
||||||
|
onSelectSession,
|
||||||
|
onRenameSession,
|
||||||
|
onDeleteSession
|
||||||
|
}: AgentHistoryPanelProps) {
|
||||||
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
|
const [draftTitle, setDraftTitle] = useState("");
|
||||||
|
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
||||||
|
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||||
|
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const beginRename = (session: AgentChatSessionSummary) => {
|
||||||
|
setEditingSessionId(session.id);
|
||||||
|
setDraftTitle(session.title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRename = () => {
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setDraftTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRename = (session: AgentChatSessionSummary) => {
|
||||||
|
const normalizedTitle = draftTitle.trim();
|
||||||
|
if (!normalizedTitle || normalizedTitle === session.title) {
|
||||||
|
cancelRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingSessionId(session.id);
|
||||||
|
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
||||||
|
.then(cancelRename)
|
||||||
|
.finally(() => setSavingSessionId(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (sessionId: string) => {
|
||||||
|
setDeletingSessionId(sessionId);
|
||||||
|
void Promise.resolve(onDeleteSession?.(sessionId))
|
||||||
|
.then(() => setConfirmingDeleteSessionId(null))
|
||||||
|
.finally(() => setDeletingSessionId(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
>
|
||||||
|
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||||
|
正在加载
|
||||||
|
</motion.div>
|
||||||
|
) : sessions.length > 0 ? (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const active = session.id === activeSessionId;
|
||||||
|
const itemLoading = loadingSessionId === session.id;
|
||||||
|
const editing = editingSessionId === session.id;
|
||||||
|
const saving = savingSessionId === session.id;
|
||||||
|
const deleting = deletingSessionId === session.id;
|
||||||
|
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
transition={agentLayoutTransition}
|
||||||
|
>
|
||||||
|
<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}>
|
||||||
|
删除“{session.title}”?
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||||
|
删除后无法从历史记录中恢复
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
) : editing ? (
|
||||||
|
<motion.form
|
||||||
|
key="rename"
|
||||||
|
className="min-w-0"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
saveRename(session);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-8 rounded-lg bg-white text-xs"
|
||||||
|
value={draftTitle}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.form>
|
||||||
|
) : (
|
||||||
|
<motion.button
|
||||||
|
key="session-summary"
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 text-left"
|
||||||
|
disabled={itemLoading || deleting}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{formatSessionUpdatedAt(session.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{session.runStatus === "running" || session.isStreaming ? (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||||
|
) : null}
|
||||||
|
{itemLoading || saving || deleting ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
onClick={() => confirmDelete(session.id)}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</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"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : editing ? (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
onClick={() => saveRename(session)}
|
||||||
|
>
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
</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"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={cancelRename}
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</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"
|
||||||
|
disabled={itemLoading || deleting || !onRenameSession}
|
||||||
|
onClick={() => beginRename(session)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} aria-hidden="true" />
|
||||||
|
</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"
|
||||||
|
disabled={itemLoading || deleting || !onDeleteSession}
|
||||||
|
onClick={() => {
|
||||||
|
cancelRename();
|
||||||
|
setConfirmingDeleteSessionId(session.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionUpdatedAt(value: number) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
HelpCircle,
|
||||||
|
ListChecks,
|
||||||
|
ListTree,
|
||||||
|
LockKeyhole,
|
||||||
|
ShieldCheck,
|
||||||
|
XCircle,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
agentEase,
|
||||||
|
agentLayoutTransition,
|
||||||
|
agentPanelItemVariants,
|
||||||
|
agentPanelListVariants,
|
||||||
|
agentPanelSectionVariants
|
||||||
|
} from "./agent-motion";
|
||||||
|
import type {
|
||||||
|
AgentChatMessage,
|
||||||
|
AgentChatProgress,
|
||||||
|
AgentPermissionReply,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentQuestionInfo,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
type ProgressStatus = AgentChatProgress["status"];
|
||||||
|
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
|
||||||
|
type PermissionStatus = AgentPermissionRequest["status"];
|
||||||
|
type QuestionStatus = AgentQuestionRequest["status"];
|
||||||
|
|
||||||
|
const STREAMING_PROGRESS_LIMIT = 3;
|
||||||
|
|
||||||
|
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||||
|
running: {
|
||||||
|
label: "进行中",
|
||||||
|
className: "bg-blue-100 text-blue-700",
|
||||||
|
dotClassName: "border-blue-500 bg-blue-100"
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "完成",
|
||||||
|
className: "bg-emerald-100 text-emerald-700",
|
||||||
|
dotClassName: "border-emerald-500 bg-emerald-100"
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: "失败",
|
||||||
|
className: "bg-red-100 text-red-700",
|
||||||
|
dotClassName: "border-red-500 bg-red-100"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
||||||
|
completed: {
|
||||||
|
label: "完成",
|
||||||
|
className: "bg-emerald-100 text-emerald-700",
|
||||||
|
dotClassName: "border-emerald-500 bg-emerald-500"
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
label: "进行中",
|
||||||
|
className: "bg-blue-100 text-blue-700",
|
||||||
|
dotClassName: "border-blue-500 bg-blue-100"
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: "待办",
|
||||||
|
className: "bg-slate-100 text-slate-600",
|
||||||
|
dotClassName: "border-slate-300 bg-white"
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
label: "取消",
|
||||||
|
className: "bg-slate-200 text-slate-500",
|
||||||
|
dotClassName: "border-slate-300 bg-slate-200"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentMessageDetails({
|
||||||
|
message,
|
||||||
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion
|
||||||
|
}: {
|
||||||
|
message: AgentChatMessage;
|
||||||
|
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||||
|
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||||
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const hasDetails = Boolean(
|
||||||
|
message.todos?.todos.length ||
|
||||||
|
message.permissions?.length ||
|
||||||
|
message.questions?.length
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasDetails) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div className="space-y-2" variants={agentPanelListVariants} animate="animate">
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{message.todos ? (
|
||||||
|
<motion.div key="todos" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||||
|
<TodoCard todoUpdate={message.todos} />
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
{message.permissions?.length ? (
|
||||||
|
<motion.div key="permissions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||||
|
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
{message.questions?.length ? (
|
||||||
|
<motion.div key="questions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||||
|
<QuestionList
|
||||||
|
questions={message.questions}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentMessageProgress({
|
||||||
|
progress,
|
||||||
|
running
|
||||||
|
}: {
|
||||||
|
progress?: AgentChatProgress[];
|
||||||
|
running: boolean;
|
||||||
|
}) {
|
||||||
|
if (!progress?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProgressList progress={progress} running={running} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentNestedBlock({
|
||||||
|
icon: Icon,
|
||||||
|
iconClassName,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
floating = false
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconClassName: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
floating?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(floating ? "px-0 py-1" : "agent-panel-nested rounded-xl p-2.5")}>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||||
|
<Icon size={14} className={iconClassName} aria-hidden="true" />
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
transition={agentLayoutTransition}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const compact = running && !expanded;
|
||||||
|
const visibleProgress = expanded
|
||||||
|
? progress
|
||||||
|
: running
|
||||||
|
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||||
|
: [];
|
||||||
|
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} 条`}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
{visibleProgress.length ? (
|
||||||
|
<motion.ol
|
||||||
|
key={listMode}
|
||||||
|
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||||
|
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={agentLayoutTransition}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{visibleProgress.map((item) => (
|
||||||
|
<motion.li
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
|
||||||
|
compact && "h-12 overflow-hidden"
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block font-semibold text-slate-800",
|
||||||
|
compact ? "truncate" : "break-words"
|
||||||
|
)}
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.detail ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block leading-5 text-slate-500",
|
||||||
|
compact ? "truncate" : "break-words"
|
||||||
|
)}
|
||||||
|
title={item.detail}
|
||||||
|
>
|
||||||
|
{item.detail}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={
|
||||||
|
item.status === "error"
|
||||||
|
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||||
|
: { opacity: 1, x: 0 }
|
||||||
|
}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={
|
||||||
|
item.status === "error"
|
||||||
|
? { duration: 0.24, ease: agentEase }
|
||||||
|
: { duration: 0.14 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{progressStatusMeta[item.status].label}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.ol>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||||
|
return (
|
||||||
|
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||||
|
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||||
|
<motion.li
|
||||||
|
key={todo.id}
|
||||||
|
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||||
|
</span>
|
||||||
|
<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)}>
|
||||||
|
{todoStatusMeta[todo.status].label}
|
||||||
|
</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.ol>
|
||||||
|
</AgentNestedBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionList({
|
||||||
|
permissions,
|
||||||
|
onReplyPermission
|
||||||
|
}: {
|
||||||
|
permissions: AgentPermissionRequest[];
|
||||||
|
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||||
|
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{permissions.slice(-4).map((permission) => (
|
||||||
|
<AnimatedDetailItem key={permission.requestId}>
|
||||||
|
<PermissionRequestCard
|
||||||
|
permission={permission}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
|
/>
|
||||||
|
</AnimatedDetailItem>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AgentNestedBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionRequestCard({
|
||||||
|
permission,
|
||||||
|
onReplyPermission
|
||||||
|
}: {
|
||||||
|
permission: AgentPermissionRequest;
|
||||||
|
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const actionable = permission.status === "pending" || permission.status === "error";
|
||||||
|
const submitting = permission.status === "submitting";
|
||||||
|
const canApproveAlways = permission.always.length > 0;
|
||||||
|
const disabled = !onReplyPermission || submitting;
|
||||||
|
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
|
||||||
|
|
||||||
|
const reply = (nextReply: AgentPermissionReply) => {
|
||||||
|
if (!onReplyPermission || submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.resolve(onReplyPermission(permission, nextReply));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
|
||||||
|
{target}
|
||||||
|
</p>
|
||||||
|
{permission.error ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||||
|
{permissionStatusMeta[permission.status].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{actionable ? (
|
||||||
|
<motion.div
|
||||||
|
key="permission-actions"
|
||||||
|
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelSectionVariants}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => reply("once")}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} aria-hidden="true" />
|
||||||
|
允许一次
|
||||||
|
</Button>
|
||||||
|
{canApproveAlways ? (
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => reply("always")}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={13} aria-hidden="true" />
|
||||||
|
始终允许
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => reply("reject")}
|
||||||
|
>
|
||||||
|
<XCircle size={13} aria-hidden="true" />
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionList({
|
||||||
|
questions,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion
|
||||||
|
}: {
|
||||||
|
questions: AgentQuestionRequest[];
|
||||||
|
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||||
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
|
||||||
|
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{questions.slice(-3).map((request) => (
|
||||||
|
<AnimatedDetailItem key={request.requestId}>
|
||||||
|
<QuestionRequestCard
|
||||||
|
request={request}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
</AnimatedDetailItem>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AgentNestedBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionRequestCard({
|
||||||
|
request,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion
|
||||||
|
}: {
|
||||||
|
request: AgentQuestionRequest;
|
||||||
|
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||||
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
|
||||||
|
const actionable = request.status === "pending" || request.status === "error";
|
||||||
|
const submitting = request.status === "submitting";
|
||||||
|
const disabled = submitting || !onReplyQuestion;
|
||||||
|
const answers = normalizeDraftAnswers(draftAnswers);
|
||||||
|
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!onReplyQuestion || !canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.resolve(onReplyQuestion(request, answers));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = () => {
|
||||||
|
if (!onRejectQuestion || submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.resolve(onRejectQuestion(request));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-0 py-1 text-xs">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<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)}>
|
||||||
|
{questionStatusMeta[request.status].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{request.questions.map((question, questionIndex) => (
|
||||||
|
<QuestionInput
|
||||||
|
key={`${request.requestId}-${questionIndex}`}
|
||||||
|
question={question}
|
||||||
|
questionIndex={questionIndex}
|
||||||
|
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||||
|
disabled={!actionable || submitting}
|
||||||
|
onChange={(nextValue) =>
|
||||||
|
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.error ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
|
||||||
|
) : null}
|
||||||
|
{request.answers?.length ? (
|
||||||
|
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||||
|
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{actionable ? (
|
||||||
|
<motion.div
|
||||||
|
key="question-actions"
|
||||||
|
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelSectionVariants}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||||
|
disabled={disabled || !canSubmit}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} aria-hidden="true" />
|
||||||
|
提交回答
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
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"
|
||||||
|
disabled={submitting || !onRejectQuestion}
|
||||||
|
onClick={reject}
|
||||||
|
>
|
||||||
|
<XCircle size={13} aria-hidden="true" />
|
||||||
|
跳过
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionDraftAnswer = {
|
||||||
|
selected: string[];
|
||||||
|
custom: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function QuestionInput({
|
||||||
|
question,
|
||||||
|
questionIndex,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
question: AgentQuestionInfo;
|
||||||
|
questionIndex: number;
|
||||||
|
value: QuestionDraftAnswer;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: QuestionDraftAnswer) => void;
|
||||||
|
}) {
|
||||||
|
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||||
|
const showCustomInput = question.custom || question.options.length === 0;
|
||||||
|
|
||||||
|
const toggleOption = (label: string, checked: boolean) => {
|
||||||
|
if (question.multiple) {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
selected: checked ? [label] : []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||||
|
{question.options.length ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={question.multiple ? "checkbox" : "radio"}
|
||||||
|
name={name}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-semibold text-slate-700">{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="block leading-4 text-slate-500">{option.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||||
|
placeholder="输入回答"
|
||||||
|
value={value.custom}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||||
|
return request.questions.map((question, index) => ({
|
||||||
|
selected: request.answers?.[index] ?? [],
|
||||||
|
custom: ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||||
|
return draftAnswers.map((answer) => {
|
||||||
|
const custom = answer.custom.trim();
|
||||||
|
return custom ? [...answer.selected, custom] : answer.selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionTitle(permission: AgentPermissionRequest) {
|
||||||
|
if (permission.permission === "bash") return "执行终端命令";
|
||||||
|
if (permission.permission === "edit") return "修改文件内容";
|
||||||
|
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||||
|
return permission.permission || "工具权限请求";
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
|
||||||
|
export const AGENT_MOTION_DURATION_MS = 180;
|
||||||
|
|
||||||
|
export const agentEase = [0.22, 1, 0.36, 1] as const;
|
||||||
|
|
||||||
|
export const agentPanelItemVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 6,
|
||||||
|
scale: 0.985
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: agentEase
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -4,
|
||||||
|
scale: 0.99,
|
||||||
|
transition: {
|
||||||
|
duration: 0.14,
|
||||||
|
ease: [0.5, 0, 0.2, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const agentPanelSectionVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
height: 0,
|
||||||
|
y: -4
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
height: "auto",
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: agentEase
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
height: 0,
|
||||||
|
y: -4,
|
||||||
|
transition: {
|
||||||
|
duration: 0.14,
|
||||||
|
ease: [0.5, 0, 0.2, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const agentPanelListVariants: Variants = {
|
||||||
|
animate: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.025
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const agentLayoutTransition = {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: agentEase
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
Crosshair,
|
||||||
|
MapPinned,
|
||||||
|
Radio,
|
||||||
|
Route
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
AgentChatMessage
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function AgentOperationalBrief({
|
||||||
|
messages,
|
||||||
|
statusLabel,
|
||||||
|
streaming,
|
||||||
|
onSubmitCommand
|
||||||
|
}: {
|
||||||
|
messages: AgentChatMessage[];
|
||||||
|
statusLabel: string;
|
||||||
|
streaming: boolean;
|
||||||
|
onSubmitCommand: (prompt: string) => void;
|
||||||
|
}) {
|
||||||
|
const brief = createOperationalBrief(messages, statusLabel, streaming);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<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"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||||
|
>
|
||||||
|
<MapPinned size={14} aria-hidden="true" />
|
||||||
|
预览影响
|
||||||
|
</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"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||||
|
>
|
||||||
|
<Crosshair size={14} aria-hidden="true" />
|
||||||
|
检查证据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<span className="text-blue-600">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
|
||||||
|
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
evidence,
|
||||||
|
plan,
|
||||||
|
confirmation,
|
||||||
|
stateLabel,
|
||||||
|
statusDotClassName,
|
||||||
|
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||||
|
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const LazyPersona = lazy(async () => {
|
||||||
|
const module = await import("@/shared/ai-elements/persona");
|
||||||
|
return { default: module.Persona };
|
||||||
|
});
|
||||||
|
|
||||||
|
type AgentPersonaProps = {
|
||||||
|
className?: string;
|
||||||
|
state?: PersonaState;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useElementVisibility() {
|
||||||
|
const containerRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let intersectsViewport = false;
|
||||||
|
const updateVisibility = () => {
|
||||||
|
setVisible(intersectsViewport && document.visibilityState === "visible");
|
||||||
|
};
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
document.addEventListener("visibilitychange", updateVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", updateVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { containerRef, visible };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonaFallback() {
|
||||||
|
return (
|
||||||
|
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
|
||||||
|
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||||
|
const { containerRef, visible } = useElementVisibility();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||||
|
|
||||||
|
const shouldRenderAnimation = !failed && visible;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={containerRef} className={cn("relative block shrink-0", className)}>
|
||||||
|
{shouldRenderAnimation ? (
|
||||||
|
<Suspense fallback={<PersonaFallback />}>
|
||||||
|
<LazyPersona
|
||||||
|
className="h-full w-full scale-110"
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
state={state}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<PersonaFallback />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||||
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||||
|
import type { AgentUiResult } from "../types";
|
||||||
|
import type { UIEnvelope } from "../ui-envelope";
|
||||||
|
import {
|
||||||
|
agentLayoutTransition,
|
||||||
|
agentPanelItemVariants,
|
||||||
|
agentPanelListVariants
|
||||||
|
} from "./agent-motion";
|
||||||
|
|
||||||
|
type AgentUiEnvelopeRendererProps = {
|
||||||
|
results: AgentUiResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
{results.map((result) => (
|
||||||
|
<motion.div
|
||||||
|
key={result.id}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={agentPanelItemVariants}
|
||||||
|
transition={agentLayoutTransition}
|
||||||
|
>
|
||||||
|
<AgentUiResultCard result={result} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</MotionConfig>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||||
|
const { envelope } = result;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
||||||
|
{getEnvelopeIcon(envelope)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
||||||
|
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
|
||||||
|
agent-ui@1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
|
||||||
|
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
|
||||||
|
{envelope.kind === "map_action" ? null : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||||
|
const spec = parseChartSpec(envelope.spec);
|
||||||
|
const data = normalizeSafeChartData(envelope.data);
|
||||||
|
|
||||||
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||||
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="agent-panel-nested rounded-xl p-3">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||||
|
{spec.title ?? "Agent 图表"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||||
|
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||||
|
{data.series.slice(0, 3).map((series, index) => (
|
||||||
|
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
|
||||||
|
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
|
||||||
|
{series.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SafeChartSvg spec={spec} data={data} />
|
||||||
|
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
||||||
|
const width = 360;
|
||||||
|
const height = 190;
|
||||||
|
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
||||||
|
const plotWidth = width - padding.left - padding.right;
|
||||||
|
const plotHeight = height - padding.top - padding.bottom;
|
||||||
|
const values = data.series.flatMap((series) => series.data);
|
||||||
|
const min = Math.min(0, ...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const xFor = (index: number) =>
|
||||||
|
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
||||||
|
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
||||||
|
const ticks = createTicks(min, max);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
||||||
|
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
||||||
|
{ticks.map((tick) => {
|
||||||
|
const y = yFor(tick);
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
||||||
|
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
||||||
|
{formatChartNumber(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||||
|
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||||
|
{data.xData.map((label, index) => (
|
||||||
|
<text
|
||||||
|
key={`${label}-${index}`}
|
||||||
|
x={xFor(index)}
|
||||||
|
y={height - 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-slate-500 text-xs"
|
||||||
|
>
|
||||||
|
{shortLabel(label)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
||||||
|
(series.type ?? spec.chartType) === "bar" ? (
|
||||||
|
<BarSeries
|
||||||
|
key={series.name}
|
||||||
|
series={series}
|
||||||
|
seriesIndex={seriesIndex}
|
||||||
|
seriesCount={Math.min(data.series.length, 4)}
|
||||||
|
xFor={xFor}
|
||||||
|
yFor={yFor}
|
||||||
|
baselineY={yFor(0)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineSeries({
|
||||||
|
series,
|
||||||
|
seriesIndex,
|
||||||
|
xFor,
|
||||||
|
yFor
|
||||||
|
}: {
|
||||||
|
series: SafeChartSeries;
|
||||||
|
seriesIndex: number;
|
||||||
|
xFor: (index: number) => number;
|
||||||
|
yFor: (value: number) => number;
|
||||||
|
}) {
|
||||||
|
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
||||||
|
const stroke = chartStroke(seriesIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="agent-chart-line"
|
||||||
|
/>
|
||||||
|
{series.data.map((value, index) => (
|
||||||
|
<circle
|
||||||
|
key={`${series.name}-${index}`}
|
||||||
|
cx={xFor(index)}
|
||||||
|
cy={yFor(value)}
|
||||||
|
r="2.8"
|
||||||
|
fill={stroke}
|
||||||
|
className="agent-chart-point"
|
||||||
|
style={{ animationDelay: `${80 + index * 18}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarSeries({
|
||||||
|
series,
|
||||||
|
seriesIndex,
|
||||||
|
seriesCount,
|
||||||
|
xFor,
|
||||||
|
yFor,
|
||||||
|
baselineY
|
||||||
|
}: {
|
||||||
|
series: SafeChartSeries;
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesCount: number;
|
||||||
|
xFor: (index: number) => number;
|
||||||
|
yFor: (value: number) => number;
|
||||||
|
baselineY: number;
|
||||||
|
}) {
|
||||||
|
const fill = chartStroke(seriesIndex);
|
||||||
|
const barWidth = Math.max(5, 22 / seriesCount);
|
||||||
|
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{series.data.map((value, index) => {
|
||||||
|
const y = yFor(value);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`${series.name}-${index}`}
|
||||||
|
x={xFor(index) - barWidth / 2 + offset}
|
||||||
|
y={Math.min(y, baselineY)}
|
||||||
|
width={barWidth}
|
||||||
|
height={Math.max(1, Math.abs(baselineY - y))}
|
||||||
|
rx="2"
|
||||||
|
fill={fill}
|
||||||
|
className="agent-chart-bar"
|
||||||
|
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegisteredComponentEnvelope({
|
||||||
|
envelope
|
||||||
|
}: {
|
||||||
|
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
|
||||||
|
}) {
|
||||||
|
if (envelope.component === "HistoryPanel") {
|
||||||
|
return (
|
||||||
|
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
||||||
|
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
|
||||||
|
</TrustedPanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.component === "ScadaPanel") {
|
||||||
|
return (
|
||||||
|
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
||||||
|
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
|
||||||
|
</TrustedPanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrustedPanelShell({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
|
||||||
|
const rows = safePropRows(props, labels);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <p className="text-sm text-slate-600">已打开可信面板,暂无可展示参数。</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-2 gap-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
||||||
|
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
||||||
|
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
||||||
|
{row.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FallbackText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePropRows(props: unknown, labels: Record<string, string>) {
|
||||||
|
if (!isRecord(props)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(labels).flatMap(([key, label]) => {
|
||||||
|
const value = props[key];
|
||||||
|
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label, value: String(value) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvelopeIcon(envelope: UIEnvelope) {
|
||||||
|
if (envelope.kind === "chart") {
|
||||||
|
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
|
||||||
|
<BarChart3 size={16} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<LineChart size={16} aria-hidden="true" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.kind === "registered_component") {
|
||||||
|
return <Database size={16} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Activity size={16} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||||
|
if (envelope.kind === "chart") {
|
||||||
|
const spec = parseChartSpec(envelope.spec);
|
||||||
|
return spec.title ?? "Agent 图表";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.kind === "registered_component") {
|
||||||
|
return envelope.component;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
||||||
|
if (surface === "chat_inline") return "聊天内联展示";
|
||||||
|
if (surface === "side_panel") return "侧栏展示";
|
||||||
|
if (surface === "canvas") return "画布展示";
|
||||||
|
return "地图叠加";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTicks(min: number, max: number) {
|
||||||
|
const range = max - min || 1;
|
||||||
|
return [max, min + range * 0.5, min];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartStroke(index: number) {
|
||||||
|
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartColorClass(index: number) {
|
||||||
|
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChartNumber(value: number) {
|
||||||
|
if (Math.abs(value) >= 100) {
|
||||||
|
return value.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(value) >= 10) {
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortLabel(value: string) {
|
||||||
|
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||||
|
|
||||||
|
type StreamingTokenResponseProps = {
|
||||||
|
content: string;
|
||||||
|
streaming: boolean;
|
||||||
|
streamDone?: boolean;
|
||||||
|
className?: string;
|
||||||
|
messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StreamingTokenResponse({
|
||||||
|
content,
|
||||||
|
streaming,
|
||||||
|
streamDone = !streaming,
|
||||||
|
className,
|
||||||
|
}: StreamingTokenResponseProps) {
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnimating = streaming && !streamDone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageResponse
|
||||||
|
className={cn("agent-streaming-response", className)}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
mode="streaming"
|
||||||
|
parseIncompleteMarkdown={true}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</MessageResponse>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
parseFrontendActionRequest,
|
||||||
|
readActionResults,
|
||||||
|
storeActionResult,
|
||||||
|
type FrontendActionRequest,
|
||||||
|
type FrontendActionResult
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||||
|
type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise<void>;
|
||||||
|
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
|
||||||
|
|
||||||
|
export class FrontendActionExecutor {
|
||||||
|
private readonly controllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {}
|
||||||
|
|
||||||
|
async handle(value: unknown, activeSessionId: string | null): Promise<void> {
|
||||||
|
const request = parseFrontendActionRequest(value);
|
||||||
|
if (!request || request.sessionId !== activeSessionId) return;
|
||||||
|
const saved = readActionResults(request.sessionId)[request.actionId];
|
||||||
|
if (saved) {
|
||||||
|
await this.submit(request.sessionId, request.actionId, saved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.controllers.has(request.actionId)) return;
|
||||||
|
const result = await this.executeOnce(request);
|
||||||
|
storeActionResult(request.sessionId, result);
|
||||||
|
await this.submit(request.sessionId, request.actionId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAll(): void {
|
||||||
|
for (const controller of this.controllers.values()) controller.abort();
|
||||||
|
this.controllers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeOnce(request: FrontendActionRequest): Promise<FrontendActionResult> {
|
||||||
|
if (Date.now() >= request.expiresAt) return this.failure(request, "expired", "ACTION_EXPIRED", "frontend action expired before execution");
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.controllers.set(request.actionId, controller);
|
||||||
|
try {
|
||||||
|
return { version: "frontend-action-result@1", actionId: request.actionId, status: "succeeded", output: await this.execute(request, controller.signal), completedAt: Date.now() };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "frontend action failed";
|
||||||
|
const code = controller.signal.aborted ? "ACTION_CANCELLED" : ERROR_CODE_PATTERN.test(message) ? message : "ACTION_FAILED";
|
||||||
|
return this.failure(request, controller.signal.aborted ? "cancelled" : "failed", code, message);
|
||||||
|
} finally {
|
||||||
|
this.controllers.delete(request.actionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failure(request: FrontendActionRequest, status: "failed" | "cancelled" | "expired", code: string, message: string): FrontendActionResult {
|
||||||
|
return { version: "frontend-action-result@1", actionId: request.actionId, status, error: { code, message }, completedAt: Date.now() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { FrontendActionExecutor } from "./executor";
|
||||||
|
import { parseFrontendActionRegistry, parseFrontendActionRequest, type FrontendActionResult } from ".";
|
||||||
|
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SCADA frontend actions", () => {
|
||||||
|
it("accepts only the two registered SCADA action names", () => {
|
||||||
|
expect(parseFrontendActionRegistry({
|
||||||
|
schema_version: "frontend-action-registry@1",
|
||||||
|
actions: [
|
||||||
|
{ id: "render_scada_analysis", version: "frontend-action@1" },
|
||||||
|
{ id: "clear_scada_analysis", version: "frontend-action@1" },
|
||||||
|
{ id: "zoom_to_map", version: "frontend-action@1" }
|
||||||
|
]
|
||||||
|
})?.actions.map((action) => action.id)).toEqual([
|
||||||
|
"render_scada_analysis",
|
||||||
|
"clear_scada_analysis"
|
||||||
|
]);
|
||||||
|
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
|
||||||
|
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the real browser output and replays the terminal receipt idempotently", async () => {
|
||||||
|
const execute = vi.fn(async () => ({
|
||||||
|
rendered_ids: ["MP01"],
|
||||||
|
missing_ids: [],
|
||||||
|
level_counts: { high: 1, medium: 0, low: 0, unrated: 0 },
|
||||||
|
fitted: true
|
||||||
|
}));
|
||||||
|
const submit = vi.fn(async (
|
||||||
|
_sessionId: string,
|
||||||
|
_actionId: string,
|
||||||
|
_result: FrontendActionResult
|
||||||
|
) => undefined);
|
||||||
|
const executor = new FrontendActionExecutor(execute, submit);
|
||||||
|
const request = createRequest("render_scada_analysis");
|
||||||
|
await executor.handle(request, "session-1");
|
||||||
|
await executor.handle(request, "session-1");
|
||||||
|
expect(execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submit).toHaveBeenCalledTimes(2);
|
||||||
|
expect(submit.mock.calls[0][2]).toMatchObject({
|
||||||
|
status: "succeeded",
|
||||||
|
output: { rendered_ids: ["MP01"], fitted: true }
|
||||||
|
});
|
||||||
|
expect(submit.mock.calls[1][2]).toEqual(submit.mock.calls[0][2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createRequest(name: string) {
|
||||||
|
return {
|
||||||
|
version: "frontend-action@1",
|
||||||
|
actionId: "action-1",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
name,
|
||||||
|
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 15_000
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
|
||||||
|
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
|
||||||
|
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
|
||||||
|
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
|
||||||
|
export type FrontendActionResult = { version: "frontend-action-result@1"; actionId: string; status: "succeeded" | "failed" | "cancelled" | "expired"; output?: unknown; error?: { code: string; message: string }; completedAt: number };
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter((item): item is { id: FrontendActionName; version: "frontend-action@1" } => isRecord(item) && FRONTEND_ACTION_NAMES.includes(item.id as FrontendActionName) && item.version === "frontend-action@1"); return { schema_version: "frontend-action-registry@1", actions }; }
|
||||||
|
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null { if (!isRecord(value) || value.version !== "frontend-action@1" || typeof value.actionId !== "string" || typeof value.toolCallId !== "string" || typeof value.sessionId !== "string" || !FRONTEND_ACTION_NAMES.includes(value.name as FrontendActionName) || !isRecord(value.params) || typeof value.issuedAt !== "number" || typeof value.expiresAt !== "number") return null; return value as FrontendActionRequest; }
|
||||||
|
const storageKey = (sessionId: string) => `tjwater:frontend-actions:${sessionId}`;
|
||||||
|
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record<string, FrontendActionResult> : {}; } catch { return {}; } }
|
||||||
|
export function storeActionResult(sessionId: string, result: FrontendActionResult) { const values = { ...readActionResults(sessionId), [result.actionId]: result }; const bounded = Object.fromEntries(Object.entries(values).sort(([, a], [, b]) => b.completedAt - a.completedAt).slice(0, 100)); sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded)); }
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
splitSpeechTextIntoChunks,
|
||||||
|
type AgentSpeakOptions,
|
||||||
|
type AgentSpeechState
|
||||||
|
} from "../speech";
|
||||||
|
|
||||||
|
interface BrowserSpeechRecognitionEvent extends Event {
|
||||||
|
readonly resultIndex: number;
|
||||||
|
readonly results: SpeechRecognitionResultList;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserSpeechRecognitionErrorEvent extends Event {
|
||||||
|
readonly error: string;
|
||||||
|
readonly message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserSpeechRecognition extends EventTarget {
|
||||||
|
lang: string;
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
onresult: ((event: BrowserSpeechRecognitionEvent) => void) | null;
|
||||||
|
onerror: ((event: BrowserSpeechRecognitionErrorEvent) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: BrowserSpeechRecognitionConstructor;
|
||||||
|
webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeToBrowserCapabilities = () => () => undefined;
|
||||||
|
|
||||||
|
function getSpeechSynthesisSupport() {
|
||||||
|
return (
|
||||||
|
typeof window.Audio !== "undefined" &&
|
||||||
|
typeof window.URL !== "undefined" &&
|
||||||
|
typeof window.fetch !== "undefined"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeechRecognitionSupport() {
|
||||||
|
return "SpeechRecognition" in window || "webkitSpeechRecognition" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerBrowserCapability() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentSpeechSynthesis() {
|
||||||
|
const [speechState, setSpeechState] = useState<AgentSpeechState>("idle");
|
||||||
|
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const currentAudioUrlRef = useRef<string | null>(null);
|
||||||
|
const audioUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
const fetchControllersRef = useRef<Set<AbortController>>(new Set());
|
||||||
|
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
|
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
|
||||||
|
const chunksRef = useRef<string[]>([]);
|
||||||
|
const currentChunkIndexRef = useRef(0);
|
||||||
|
const playbackTokenRef = useRef(0);
|
||||||
|
const activeMessageIdRef = useRef<string | null>(null);
|
||||||
|
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(async () => undefined);
|
||||||
|
|
||||||
|
const isSupported = useSyncExternalStore(
|
||||||
|
subscribeToBrowserCapabilities,
|
||||||
|
getSpeechSynthesisSupport,
|
||||||
|
getServerBrowserCapability
|
||||||
|
);
|
||||||
|
|
||||||
|
const detachAudio = useCallback((revokeUrl: boolean) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
audioRef.current = null;
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUrl = currentAudioUrlRef.current;
|
||||||
|
currentAudioUrlRef.current = null;
|
||||||
|
if (revokeUrl && currentUrl) {
|
||||||
|
URL.revokeObjectURL(currentUrl);
|
||||||
|
audioUrlsRef.current.delete(currentUrl);
|
||||||
|
chunkUrlCacheRef.current.delete(currentChunkIndexRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const releaseAudio = useCallback(() => {
|
||||||
|
fetchControllersRef.current.forEach((controller) => controller.abort());
|
||||||
|
fetchControllersRef.current.clear();
|
||||||
|
chunkPromisesRef.current.clear();
|
||||||
|
detachAudio(false);
|
||||||
|
audioUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
audioUrlsRef.current.clear();
|
||||||
|
chunkUrlCacheRef.current.clear();
|
||||||
|
chunksRef.current = [];
|
||||||
|
currentChunkIndexRef.current = 0;
|
||||||
|
activeMessageIdRef.current = null;
|
||||||
|
}, [detachAudio]);
|
||||||
|
|
||||||
|
const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => {
|
||||||
|
const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex);
|
||||||
|
if (cachedUrl) return Promise.resolve(cachedUrl);
|
||||||
|
|
||||||
|
const currentPromise = chunkPromisesRef.current.get(chunkIndex);
|
||||||
|
if (currentPromise) return currentPromise;
|
||||||
|
|
||||||
|
const text = chunksRef.current[chunkIndex];
|
||||||
|
if (!text) return Promise.reject(new Error("语音片段不存在"));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchControllersRef.current.add(controller);
|
||||||
|
const promise = fetch("/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error ?? `语音生成失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
const audioBlob = await response.blob();
|
||||||
|
if (!audioBlob.size) throw new Error("语音服务返回了空音频");
|
||||||
|
if (playbackToken !== playbackTokenRef.current) {
|
||||||
|
throw new DOMException("语音播放已取消", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(audioBlob);
|
||||||
|
audioUrlsRef.current.add(objectUrl);
|
||||||
|
chunkUrlCacheRef.current.set(chunkIndex, objectUrl);
|
||||||
|
return objectUrl;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fetchControllersRef.current.delete(controller);
|
||||||
|
chunkPromisesRef.current.delete(chunkIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
chunkPromisesRef.current.set(chunkIndex, promise);
|
||||||
|
return promise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prefetchChunk = useCallback((chunkIndex: number, playbackToken: number) => {
|
||||||
|
if (chunkIndex >= chunksRef.current.length) return;
|
||||||
|
void fetchChunkAudio(chunkIndex, playbackToken).catch(() => undefined);
|
||||||
|
}, [fetchChunkAudio]);
|
||||||
|
|
||||||
|
const playChunk = useCallback(async (chunkIndex: number, playbackToken: number) => {
|
||||||
|
setSpeechState("loading");
|
||||||
|
const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken);
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
|
||||||
|
detachAudio(true);
|
||||||
|
currentChunkIndexRef.current = chunkIndex;
|
||||||
|
const audio = new Audio(objectUrl);
|
||||||
|
audio.preload = "auto";
|
||||||
|
audioRef.current = audio;
|
||||||
|
currentAudioUrlRef.current = objectUrl;
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
detachAudio(true);
|
||||||
|
const nextChunkIndex = chunkIndex + 1;
|
||||||
|
if (nextChunkIndex >= chunksRef.current.length) {
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentChunkIndexRef.current = nextChunkIndex;
|
||||||
|
void playChunkRef.current(nextChunkIndex, playbackToken);
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
await audio.play();
|
||||||
|
if (playbackToken !== playbackTokenRef.current) return;
|
||||||
|
setSpeechState("playing");
|
||||||
|
prefetchChunk(chunkIndex + 1, playbackToken);
|
||||||
|
}, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
playChunkRef.current = playChunk;
|
||||||
|
}, [playChunk]);
|
||||||
|
|
||||||
|
const speak = useCallback(async (messageId: string, text: string, options: AgentSpeakOptions = {}) => {
|
||||||
|
const normalizedText = text.trim();
|
||||||
|
if (!isSupported || !normalizedText) return;
|
||||||
|
|
||||||
|
const startOffset = Math.max(0, Math.min(options.startOffset ?? 0, normalizedText.length));
|
||||||
|
const chunks = splitSpeechTextIntoChunks(normalizedText.slice(startOffset));
|
||||||
|
if (!chunks.length) return;
|
||||||
|
|
||||||
|
const playbackToken = playbackTokenRef.current + 1;
|
||||||
|
playbackTokenRef.current = playbackToken;
|
||||||
|
releaseAudio();
|
||||||
|
chunksRef.current = chunks;
|
||||||
|
activeMessageIdRef.current = messageId;
|
||||||
|
setSpeakingMessageId(messageId);
|
||||||
|
setSpeechState("loading");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await playChunk(0, playbackToken);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
}
|
||||||
|
}, [isSupported, playChunk, releaseAudio]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (!audioRef.current || speechState !== "playing") return;
|
||||||
|
audioRef.current.pause();
|
||||||
|
setSpeechState("paused");
|
||||||
|
}, [speechState]);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
setSpeakingMessageId(activeMessageIdRef.current);
|
||||||
|
setSpeechState("playing");
|
||||||
|
void audio.play().catch(() => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
});
|
||||||
|
}, [releaseAudio]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
}, [releaseAudio]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
releaseAudio();
|
||||||
|
}, [releaseAudio]);
|
||||||
|
|
||||||
|
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentSpeechRecognition(onResult: (text: string) => void) {
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const recognitionRef = useRef<BrowserSpeechRecognition | null>(null);
|
||||||
|
const onResultRef = useRef(onResult);
|
||||||
|
const onErrorRef = useRef<(message: string) => void>(() => undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onResultRef.current = onResult;
|
||||||
|
}, [onResult]);
|
||||||
|
|
||||||
|
const isSupported = useSyncExternalStore(
|
||||||
|
subscribeToBrowserCapabilities,
|
||||||
|
getSpeechRecognitionSupport,
|
||||||
|
getServerBrowserCapability
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = useCallback((onError?: (message: string) => void) => {
|
||||||
|
if (!isSupported || recognitionRef.current) return;
|
||||||
|
const Recognition = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||||
|
if (!Recognition) return;
|
||||||
|
|
||||||
|
onErrorRef.current = onError ?? (() => undefined);
|
||||||
|
|
||||||
|
const recognition = new Recognition();
|
||||||
|
recognition.lang = "zh-CN";
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = false;
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||||
|
if (event.results[index].isFinal) {
|
||||||
|
onResultRef.current(event.results[index][0].transcript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
setIsListening(false);
|
||||||
|
if (event.error !== "aborted") {
|
||||||
|
onErrorRef.current(getSpeechRecognitionErrorMessage(event.error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (recognitionRef.current === recognition) {
|
||||||
|
recognitionRef.current = null;
|
||||||
|
}
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
setIsListening(true);
|
||||||
|
} catch {
|
||||||
|
recognitionRef.current = null;
|
||||||
|
setIsListening(false);
|
||||||
|
onErrorRef.current("无法启动语音输入,请检查浏览器麦克风权限后重试");
|
||||||
|
}
|
||||||
|
}, [isSupported]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
recognitionRef.current?.stop();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
setIsListening(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => recognitionRef.current?.abort(), []);
|
||||||
|
|
||||||
|
return { isListening, start, stop, isSupported };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeechRecognitionErrorMessage(error: string) {
|
||||||
|
switch (error) {
|
||||||
|
case "not-allowed":
|
||||||
|
case "service-not-allowed":
|
||||||
|
return "无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试";
|
||||||
|
case "audio-capture":
|
||||||
|
return "未检测到可用麦克风,请检查设备连接";
|
||||||
|
case "network":
|
||||||
|
return "语音识别服务连接失败,请检查网络后重试";
|
||||||
|
case "no-speech":
|
||||||
|
return "未检测到语音,请靠近麦克风后重试";
|
||||||
|
default:
|
||||||
|
return "语音输入暂时不可用,请稍后重试";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||||
|
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||||
|
export { AgentPersona } from "./components/agent-persona";
|
||||||
|
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||||
|
export {
|
||||||
|
applyPermissionResponse,
|
||||||
|
applyQuestionResponse,
|
||||||
|
toTodoUpdate,
|
||||||
|
upsertPermission,
|
||||||
|
upsertProgress,
|
||||||
|
upsertQuestion
|
||||||
|
} from "./session-state";
|
||||||
|
export type {
|
||||||
|
AgentApprovalMode,
|
||||||
|
AgentChatMessage,
|
||||||
|
AgentChatProgress,
|
||||||
|
AgentInteractionToolRef,
|
||||||
|
AgentModelOption,
|
||||||
|
AgentPermissionReply,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentPermissionStatus,
|
||||||
|
AgentQuestionInfo,
|
||||||
|
AgentQuestionOption,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentStreamRenderMessageState,
|
||||||
|
AgentStreamRenderState,
|
||||||
|
AgentTodoItem,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
AgentUiResult
|
||||||
|
} from "./types";
|
||||||
|
export type {
|
||||||
|
SafeChartData,
|
||||||
|
SafeChartSeries,
|
||||||
|
SafeChartSpec
|
||||||
|
} from "./chart-data";
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyPermissionResponse,
|
||||||
|
applyQuestionResponse,
|
||||||
|
finalizeAssistantMessageAfterAbort,
|
||||||
|
toTodoUpdate,
|
||||||
|
upsertPermission,
|
||||||
|
upsertProgress,
|
||||||
|
upsertQuestion
|
||||||
|
} from "./session-state";
|
||||||
|
import type { AgentChatMessage } from "./types";
|
||||||
|
|
||||||
|
describe("agent session state", () => {
|
||||||
|
it("upserts progress events by id", () => {
|
||||||
|
const first = upsertProgress(undefined, {
|
||||||
|
id: "tool-1",
|
||||||
|
phase: "tool",
|
||||||
|
status: "running",
|
||||||
|
title: "调用工具",
|
||||||
|
elapsed_ms: 20
|
||||||
|
});
|
||||||
|
const next = upsertProgress(first, {
|
||||||
|
id: "tool-1",
|
||||||
|
phase: "tool",
|
||||||
|
status: "completed",
|
||||||
|
title: "工具完成",
|
||||||
|
duration_ms: 40
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toHaveLength(1);
|
||||||
|
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes todo updates", () => {
|
||||||
|
expect(
|
||||||
|
toTodoUpdate({
|
||||||
|
session_id: "session-1",
|
||||||
|
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
|
||||||
|
created_at: 123
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
sessionId: "session-1",
|
||||||
|
messageId: undefined,
|
||||||
|
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
|
||||||
|
createdAt: 123
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks permission request and response", () => {
|
||||||
|
const permissions = upsertPermission(undefined, {
|
||||||
|
session_id: "session-1",
|
||||||
|
request_id: "permission-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["pnpm test"],
|
||||||
|
always: [],
|
||||||
|
created_at: 100
|
||||||
|
});
|
||||||
|
const next = applyPermissionResponse(permissions, {
|
||||||
|
request_id: "permission-1",
|
||||||
|
reply: "once"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next?.[0]).toMatchObject({
|
||||||
|
requestId: "permission-1",
|
||||||
|
status: "approved_once"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes question requests by tool call id", () => {
|
||||||
|
const questions = upsertQuestion(undefined, {
|
||||||
|
session_id: "session-1",
|
||||||
|
request_id: "call-1",
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||||
|
created_at: 100
|
||||||
|
});
|
||||||
|
const next = upsertQuestion(questions, {
|
||||||
|
session_id: "session-1",
|
||||||
|
request_id: "question-1",
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||||
|
created_at: 120
|
||||||
|
});
|
||||||
|
const answered = applyQuestionResponse(next, {
|
||||||
|
request_id: "question-1",
|
||||||
|
answers: [["东部"]]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toHaveLength(1);
|
||||||
|
expect(answered?.[0]).toMatchObject({
|
||||||
|
requestId: "question-1",
|
||||||
|
status: "answered",
|
||||||
|
answers: [["东部"]]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes open assistant state after abort", () => {
|
||||||
|
const message: AgentChatMessage = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
progress: [{ id: "run", phase: "run", status: "running", title: "处理中", startedAt: Date.now() }],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: [],
|
||||||
|
always: [],
|
||||||
|
createdAt: 100,
|
||||||
|
status: "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = finalizeAssistantMessageAfterAbort(message);
|
||||||
|
|
||||||
|
expect(next.content).toBe("请求已中止。");
|
||||||
|
expect(next.progress?.[0].status).toBe("completed");
|
||||||
|
expect(next.permissions?.[0].status).toBe("aborted");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import type {
|
||||||
|
AgentChatMessage,
|
||||||
|
AgentChatProgress,
|
||||||
|
AgentInteractionToolRef,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentPermissionStatus,
|
||||||
|
AgentQuestionInfo,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoItem,
|
||||||
|
AgentTodoUpdate
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type RecordValue = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const index = progress?.findIndex((item) => item.id === id) ?? -1;
|
||||||
|
const existing = index >= 0 ? progress?.[index] : undefined;
|
||||||
|
const status = readProgressStatus(payload.status);
|
||||||
|
const startedAt = readNumber(payload.started_at) ?? readNumber(payload.startedAt) ?? existing?.startedAt;
|
||||||
|
const endedAt = readNumber(payload.ended_at) ?? readNumber(payload.endedAt);
|
||||||
|
const elapsedMs = readNumber(payload.elapsed_ms) ?? readNumber(payload.elapsedMs);
|
||||||
|
const durationMs = readNumber(payload.duration_ms) ?? readNumber(payload.durationMs);
|
||||||
|
const nextItem: AgentChatProgress = {
|
||||||
|
id,
|
||||||
|
phase: readString(payload.phase) ?? "progress",
|
||||||
|
status,
|
||||||
|
title: readString(payload.title) ?? "正在处理",
|
||||||
|
detail: readString(payload.detail),
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: status === "running" ? elapsedMs : undefined,
|
||||||
|
elapsedSnapshotAt: status === "running" && elapsedMs !== undefined ? now : undefined,
|
||||||
|
durationMs: status === "running" ? undefined : durationMs
|
||||||
|
};
|
||||||
|
const next = [...(progress ?? [])];
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = nextItem;
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
|
||||||
|
return progress?.map((item) => {
|
||||||
|
if (item.status !== "running") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs:
|
||||||
|
item.durationMs ??
|
||||||
|
(item.startedAt !== undefined ? Math.max(0, endedAt - item.startedAt) : item.elapsedMs)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTodoUpdate(data: unknown): AgentTodoUpdate | null {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||||
|
const rawTodos = Array.isArray(payload.todos) ? payload.todos : [];
|
||||||
|
|
||||||
|
if (!sessionId || rawTodos.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
messageId: readString(payload.message_id) ?? readString(payload.messageId),
|
||||||
|
todos: rawTodos.map(toTodoItem).filter((item): item is AgentTodoItem => Boolean(item)),
|
||||||
|
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelRunningTodos(todoUpdate: AgentTodoUpdate | undefined) {
|
||||||
|
return todoUpdate
|
||||||
|
? {
|
||||||
|
...todoUpdate,
|
||||||
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
|
...todo,
|
||||||
|
status: "cancelled" as const,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
: todo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPermission(
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
data: unknown
|
||||||
|
) {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||||
|
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||||
|
|
||||||
|
if (!requestId || !sessionId) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItem: AgentPermissionRequest = {
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
permission: readString(payload.permission) ?? "unknown",
|
||||||
|
patterns: readStringArray(payload.patterns),
|
||||||
|
target: readString(payload.target),
|
||||||
|
always: readStringArray(payload.always),
|
||||||
|
tool: toToolRef(payload.tool),
|
||||||
|
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
|
||||||
|
status: "pending"
|
||||||
|
};
|
||||||
|
const next = [...(permissions ?? [])];
|
||||||
|
const index = next.findIndex((permission) => permission.requestId === requestId);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPermissionResponse(
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
data: unknown
|
||||||
|
) {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||||
|
if (!requestId) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions?.map((permission) =>
|
||||||
|
permission.requestId === requestId
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: toPermissionStatus(readString(payload.reply)),
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
: permission
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, data: unknown) {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||||
|
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||||
|
const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
|
||||||
|
|
||||||
|
if (!requestId || !sessionId || rawQuestions.length === 0) {
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItem: AgentQuestionRequest = {
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
questions: rawQuestions.map(toQuestionInfo).filter((item): item is AgentQuestionInfo => Boolean(item)),
|
||||||
|
tool: toToolRef(payload.tool),
|
||||||
|
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
|
||||||
|
status: "pending"
|
||||||
|
};
|
||||||
|
const next = [...(questions ?? [])];
|
||||||
|
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefined, data: unknown) {
|
||||||
|
const payload = asRecord(data);
|
||||||
|
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||||
|
if (!requestId) {
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = Array.isArray(payload.answers)
|
||||||
|
? payload.answers.map((answer) => (Array.isArray(answer) ? answer.map(String) : []))
|
||||||
|
: undefined;
|
||||||
|
const rejected = payload.rejected === true;
|
||||||
|
|
||||||
|
return questions?.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: rejected ? ("rejected" as const) : ("answered" as const),
|
||||||
|
answers: answers ?? question.answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
: question
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
|
||||||
|
const progress = completeRunningProgress(message.progress);
|
||||||
|
const todos = cancelRunningTodos(message.todos);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "请求已中止。",
|
||||||
|
progress,
|
||||||
|
todos,
|
||||||
|
permissions: message.permissions?.map((permission) =>
|
||||||
|
permission.status === "pending" || permission.status === "submitting" || permission.status === "error"
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: "aborted" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
: permission
|
||||||
|
),
|
||||||
|
questions: message.questions?.map((question) =>
|
||||||
|
question.status === "pending" || question.status === "submitting" || question.status === "error"
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "rejected" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
: question
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
|
||||||
|
if (reply === "always") {
|
||||||
|
return "approved_always";
|
||||||
|
}
|
||||||
|
if (reply === "once") {
|
||||||
|
return "approved_once";
|
||||||
|
}
|
||||||
|
return "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameQuestion(left: AgentQuestionRequest, right: AgentQuestionRequest) {
|
||||||
|
if (left.requestId === right.requestId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(left.tool?.callID && right.tool?.callID && left.tool.callID === right.tool.callID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuestionInfo(value: unknown): AgentQuestionInfo | null {
|
||||||
|
const payload = asRecord(value);
|
||||||
|
const question = readString(payload.question);
|
||||||
|
if (!question) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: readString(payload.header) ?? "",
|
||||||
|
question,
|
||||||
|
options: Array.isArray(payload.options)
|
||||||
|
? payload.options
|
||||||
|
.map((option) => {
|
||||||
|
const item = asRecord(option);
|
||||||
|
const label = readString(item.label);
|
||||||
|
return label
|
||||||
|
? {
|
||||||
|
label,
|
||||||
|
description: readString(item.description) ?? ""
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((item): item is { label: string; description: string } => Boolean(item))
|
||||||
|
: [],
|
||||||
|
multiple: typeof payload.multiple === "boolean" ? payload.multiple : undefined,
|
||||||
|
custom: typeof payload.custom === "boolean" ? payload.custom : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTodoItem(value: unknown): AgentTodoItem | null {
|
||||||
|
const payload = asRecord(value);
|
||||||
|
const id = readString(payload.id);
|
||||||
|
const content = readString(payload.content);
|
||||||
|
const status = readTodoStatus(payload.status);
|
||||||
|
|
||||||
|
if (!id || !content || !status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
status,
|
||||||
|
priority: readTodoPriority(payload.priority),
|
||||||
|
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt),
|
||||||
|
updatedAt: readNumber(payload.updated_at) ?? readNumber(payload.updatedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
|
||||||
|
const payload = asRecord(value);
|
||||||
|
const messageID = readString(payload.messageID);
|
||||||
|
const callID = readString(payload.callID);
|
||||||
|
return messageID && callID ? { messageID, callID } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
|
||||||
|
return value === "completed" || value === "error" ? value : "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
|
||||||
|
if (value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTodoPriority(value: unknown): AgentTodoItem["priority"] | undefined {
|
||||||
|
if (value === "low" || value === "medium" || value === "high") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): RecordValue {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as RecordValue)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
findSpeechSelectionStartOffset,
|
||||||
|
splitSpeechTextIntoChunks,
|
||||||
|
stripSpeechMarkdown
|
||||||
|
} from "./speech";
|
||||||
|
|
||||||
|
describe("Agent 语音文本处理", () => {
|
||||||
|
it("定位经过空白压缩的选择文本", () => {
|
||||||
|
const text = "第一段内容。\n第二段 包含空格。";
|
||||||
|
expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe(text.indexOf("第二段"));
|
||||||
|
expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按句子拆分长文本", () => {
|
||||||
|
const chunks = splitSpeechTextIntoChunks(`${"甲".repeat(260)}。${"乙".repeat(260)}。`);
|
||||||
|
expect(chunks).toHaveLength(2);
|
||||||
|
expect(chunks.join("")).toContain("乙");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("移除会干扰朗读的 Markdown 标记", () => {
|
||||||
|
expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||||
|
const MAX_SPEECH_CHUNK_LENGTH = 520;
|
||||||
|
const MIN_SPEECH_CHUNK_LENGTH = 180;
|
||||||
|
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
|
||||||
|
|
||||||
|
export type AgentSpeechState = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
|
export type AgentSpeakOptions = {
|
||||||
|
startOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWithOffsetMap = (value: string) => {
|
||||||
|
let normalized = "";
|
||||||
|
const offsetMap: number[] = [];
|
||||||
|
let previousWasWhitespace = false;
|
||||||
|
|
||||||
|
Array.from(value).forEach((character, index) => {
|
||||||
|
if (/\s/u.test(character)) {
|
||||||
|
if (!previousWasWhitespace && normalized.length > 0) {
|
||||||
|
normalized += " ";
|
||||||
|
offsetMap.push(index);
|
||||||
|
}
|
||||||
|
previousWasWhitespace = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized += character;
|
||||||
|
offsetMap.push(index);
|
||||||
|
previousWasWhitespace = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { normalized: normalized.trimEnd(), offsetMap };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stripSpeechMarkdown(markdown: string) {
|
||||||
|
return markdown
|
||||||
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
|
.replace(/!\[.*?\]\(.*?\)/g, "")
|
||||||
|
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
|
||||||
|
.replace(/#{1,6}\s+/g, "")
|
||||||
|
.replace(/\*{1,3}(.+?)\*{1,3}/g, "$1")
|
||||||
|
.replace(/~~(.+?)~~/g, "$1")
|
||||||
|
.replace(/^>\s+/gm, "")
|
||||||
|
.replace(/^[-*+]\s+/gm, "")
|
||||||
|
.replace(/^\d+\.\s+/gm, "")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/\n{2,}/g, "\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSpeechSelectionStartOffset(text: string, selectedText: string): number | null {
|
||||||
|
const needle = selectedText.trim();
|
||||||
|
if (!needle) return null;
|
||||||
|
|
||||||
|
const exactIndex = text.indexOf(needle);
|
||||||
|
if (exactIndex >= 0) return exactIndex;
|
||||||
|
|
||||||
|
const normalizedNeedle = compactWhitespace(needle);
|
||||||
|
if (!normalizedNeedle) return null;
|
||||||
|
|
||||||
|
const haystack = normalizeWithOffsetMap(text);
|
||||||
|
const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle);
|
||||||
|
if (normalizedIndex < 0) return null;
|
||||||
|
|
||||||
|
return haystack.offsetMap[normalizedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitSpeechTextIntoChunks(text: string): string[] {
|
||||||
|
const normalizedText = text.trim();
|
||||||
|
if (!normalizedText) return [];
|
||||||
|
|
||||||
|
const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) =>
|
||||||
|
compactWhitespace(match[0])
|
||||||
|
).filter(Boolean);
|
||||||
|
const sourceSegments = segments.length > 0 ? segments : [normalizedText];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = "";
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!currentChunk) return;
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
currentChunk = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const segment of sourceSegments) {
|
||||||
|
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
|
||||||
|
flush();
|
||||||
|
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
|
||||||
|
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
|
||||||
|
if (
|
||||||
|
currentChunk &&
|
||||||
|
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
|
||||||
|
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
|
||||||
|
) {
|
||||||
|
flush();
|
||||||
|
currentChunk = segment;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentChunk = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("StreamingTokenResponse", () => {
|
||||||
|
it("renders the backend-smoothed stream without a second timer buffer", () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
join(process.cwd(), "src/features/agent/components/streaming-token-response.tsx"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(source).not.toContain("setInterval");
|
||||||
|
expect(source).not.toContain("useState");
|
||||||
|
expect(source).not.toContain("streaming-token-response-state");
|
||||||
|
expect(source).toContain("{content}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("Streamdown fullscreen table", () => {
|
||||||
|
it("disables inherited token animations only inside the fullscreen copy", () => {
|
||||||
|
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
|
||||||
|
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export type AgentChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
progress?: AgentChatProgress[];
|
||||||
|
permissions?: AgentPermissionRequest[];
|
||||||
|
questions?: AgentQuestionRequest[];
|
||||||
|
todos?: AgentTodoUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentStreamRenderMessageState = {
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
|
||||||
|
|
||||||
|
export type AgentChatProgress = {
|
||||||
|
id: string;
|
||||||
|
phase: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentPermissionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "submitting"
|
||||||
|
| "approved_once"
|
||||||
|
| "approved_always"
|
||||||
|
| "rejected"
|
||||||
|
| "aborted"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
|
export type AgentApprovalMode = "request" | "always";
|
||||||
|
|
||||||
|
export type AgentModelOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: "bolt" | "sparkle";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentPermissionRequest = {
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
target?: string;
|
||||||
|
always: string[];
|
||||||
|
tool?: AgentInteractionToolRef;
|
||||||
|
createdAt: number;
|
||||||
|
repliedAt?: number;
|
||||||
|
status: AgentPermissionStatus;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentQuestionOption = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentQuestionInfo = {
|
||||||
|
header: string;
|
||||||
|
question: string;
|
||||||
|
options: AgentQuestionOption[];
|
||||||
|
multiple?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentQuestionRequest = {
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
questions: AgentQuestionInfo[];
|
||||||
|
tool?: AgentInteractionToolRef;
|
||||||
|
createdAt: number;
|
||||||
|
repliedAt?: number;
|
||||||
|
status: "pending" | "submitting" | "answered" | "rejected" | "error";
|
||||||
|
answers?: string[][];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentTodoItem = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
|
priority?: "low" | "medium" | "high";
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentTodoUpdate = {
|
||||||
|
sessionId: string;
|
||||||
|
messageId?: string;
|
||||||
|
todos: AgentTodoItem[];
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentInteractionToolRef = {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentUiResult = {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
createdAt: number;
|
||||||
|
envelope: import("./ui-envelope").UIEnvelope;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
||||||
|
export { toTrustedMapAction, type TrustedMapAction } from "./map-actions";
|
||||||
|
export {
|
||||||
|
isUiEnvelopeAllowed,
|
||||||
|
parseUiEnvelope,
|
||||||
|
parseUiEnvelopePayload,
|
||||||
|
parseUiRegistry
|
||||||
|
} from "./validator";
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { toTrustedMapAction } from "./map-actions";
|
||||||
|
|
||||||
|
describe("toTrustedMapAction", () => {
|
||||||
|
it("rejects zoom_to_map actions without a valid center", () => {
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { zoom: 14 })).toBeNull();
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { center: ["east", 39.1] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported map actions", () => {
|
||||||
|
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or empty apply_layer_style params", () => {
|
||||||
|
expect(
|
||||||
|
toTrustedMapAction("apply_layer_style", {
|
||||||
|
layer_group_id: 123,
|
||||||
|
layer_id: [],
|
||||||
|
visible: "true"
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-range coordinates and zoom", () => {
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { center: [181, 39.1] })).toBeNull();
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { center: [117.2, -91] })).toBeNull();
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { center: [117.2, 39.1], zoom: 25 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty and oversized locate requests", () => {
|
||||||
|
expect(toTrustedMapAction("locate_features", { ids: [] })).toBeNull();
|
||||||
|
expect(toTrustedMapAction("locate_features", { ids: Array.from({ length: 101 }, (_, index) => `P${index}`) })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid zoom_to_map coordinates", () => {
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { lng: 117.2, lat: 39.1, zoom: 13 })).toEqual({
|
||||||
|
type: "zoom_to_map",
|
||||||
|
center: [117.2, 39.1],
|
||||||
|
zoom: 13,
|
||||||
|
fallbackText: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts legacy zoom coordinate aliases and numeric strings", () => {
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { coordinate: ["117.2", "39.1"], zoom: "13" })).toEqual({
|
||||||
|
type: "zoom_to_map",
|
||||||
|
center: [117.2, 39.1],
|
||||||
|
zoom: 13,
|
||||||
|
fallbackText: undefined
|
||||||
|
});
|
||||||
|
expect(toTrustedMapAction("zoom_to_map", { x: "117.2", y: "39.1" })).toMatchObject({
|
||||||
|
type: "zoom_to_map",
|
||||||
|
center: [117.2, 39.1]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts EPSG:3857 zoom coordinates", () => {
|
||||||
|
const action = toTrustedMapAction("zoom_to_map", { x: 13046644.321, y: 4736005.854, source_crs: "EPSG:3857" });
|
||||||
|
|
||||||
|
expect(action?.type).toBe("zoom_to_map");
|
||||||
|
expect(action?.type === "zoom_to_map" ? action.center[0] : undefined).toBeCloseTo(117.2, 3);
|
||||||
|
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy locate tool actions", () => {
|
||||||
|
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||||
|
type: "locate_features",
|
||||||
|
featureIds: ["P1", "P2"],
|
||||||
|
layer: "geo_pipes_mat",
|
||||||
|
fallbackText: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes numeric locate ids", () => {
|
||||||
|
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||||
|
type: "locate_features",
|
||||||
|
featureIds: ["42"],
|
||||||
|
layer: "geo_junctions_mat"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts supply layer visibility and scada locate actions", () => {
|
||||||
|
expect(toTrustedMapAction("apply_layer_style", { layer_id: "scada", visible: false })).toMatchObject({
|
||||||
|
type: "apply_layer_style",
|
||||||
|
layerId: "scada",
|
||||||
|
visible: false
|
||||||
|
});
|
||||||
|
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||||
|
type: "locate_features",
|
||||||
|
featureIds: ["S-1"],
|
||||||
|
layer: "geo_scada"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
export type TrustedMapAction =
|
||||||
|
| {
|
||||||
|
type: "locate_features";
|
||||||
|
featureIds: string[];
|
||||||
|
layer?: string;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "zoom_to_map";
|
||||||
|
center: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "apply_layer_style";
|
||||||
|
layerGroupId?: string;
|
||||||
|
layerId?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "render_junctions";
|
||||||
|
renderRef: string;
|
||||||
|
fallbackText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null {
|
||||||
|
if (!isRecord(params)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
||||||
|
const featureIds = readLocateIds(params);
|
||||||
|
if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null;
|
||||||
|
return {
|
||||||
|
type: "locate_features",
|
||||||
|
featureIds,
|
||||||
|
layer:
|
||||||
|
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
|
||||||
|
LEGACY_LOCATE_ACTION_LAYERS[action],
|
||||||
|
fallbackText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "zoom_to_map") {
|
||||||
|
const center = parseCenter(params);
|
||||||
|
if (!center) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = numberValue(params.zoom);
|
||||||
|
const durationMs = numberValue(params.duration_ms ?? params.durationMs);
|
||||||
|
if (center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 ||
|
||||||
|
(zoom !== undefined && (zoom < 0 || zoom > 24))) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "zoom_to_map",
|
||||||
|
center,
|
||||||
|
zoom,
|
||||||
|
...(durationMs === undefined ? {} : { durationMs }),
|
||||||
|
fallbackText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "apply_layer_style") {
|
||||||
|
const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId);
|
||||||
|
const layerId = stringValue(params.layer_id ?? params.layerId);
|
||||||
|
const visible = booleanValue(params.visible);
|
||||||
|
if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null;
|
||||||
|
return {
|
||||||
|
type: "apply_layer_style",
|
||||||
|
layerGroupId,
|
||||||
|
layerId,
|
||||||
|
visible,
|
||||||
|
fallbackText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "render_junctions") {
|
||||||
|
const renderRef = stringValue(params.render_ref ?? params.renderRef);
|
||||||
|
if (!renderRef || !RESULT_REF_PATTERN.test(renderRef)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "render_junctions",
|
||||||
|
renderRef,
|
||||||
|
fallbackText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCenter(params: Record<string, unknown>): [number, number] | null {
|
||||||
|
const sourceCrs = params.source_crs ?? params.sourceCrs;
|
||||||
|
|
||||||
|
if (Array.isArray(params.center) && params.center.length >= 2) {
|
||||||
|
const lng = numberValue(params.center[0]);
|
||||||
|
const lat = numberValue(params.center[1]);
|
||||||
|
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCoordinate = params.coordinate ?? params.coordinates;
|
||||||
|
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
|
||||||
|
const lng = numberValue(rawCoordinate[0]);
|
||||||
|
const lat = numberValue(rawCoordinate[1]);
|
||||||
|
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
|
||||||
|
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
|
||||||
|
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, number] {
|
||||||
|
if (sourceCrs === "EPSG:3857") {
|
||||||
|
return webMercatorToLngLat(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
|
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||||
|
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
|
||||||
|
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
|
||||||
|
|
||||||
|
return [lng, lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown) {
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||||
|
locate_junctions: "geo_junctions_mat",
|
||||||
|
locate_pipes: "geo_pipes_mat",
|
||||||
|
locate_valves: "geo_valves",
|
||||||
|
locate_reservoirs: "geo_reservoirs",
|
||||||
|
locate_scada: "geo_scada",
|
||||||
|
locate_pumps: "geo_pumps",
|
||||||
|
locate_tanks: "geo_tanks"
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||||
|
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes", "valves", "reservoirs", "scada"]);
|
||||||
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
|
|
||||||
|
const LOCATE_ID_PARAM_KEYS = [
|
||||||
|
"feature_ids",
|
||||||
|
"feature_id",
|
||||||
|
"featureIds",
|
||||||
|
"featureId",
|
||||||
|
"ids",
|
||||||
|
"id",
|
||||||
|
"node_ids",
|
||||||
|
"node_id",
|
||||||
|
"junction_ids",
|
||||||
|
"junction_id",
|
||||||
|
"pipe_ids",
|
||||||
|
"pipe_id",
|
||||||
|
"valve_ids",
|
||||||
|
"valve_id",
|
||||||
|
"reservoir_ids",
|
||||||
|
"reservoir_id",
|
||||||
|
"scada_ids",
|
||||||
|
"scada_id",
|
||||||
|
"pump_ids",
|
||||||
|
"pump_id",
|
||||||
|
"tank_ids",
|
||||||
|
"tank_id"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function readLocateIds(params: Record<string, unknown>) {
|
||||||
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||||
|
const value = params[key];
|
||||||
|
const ids = normalizeIds(value);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIds(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return String(value)
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay";
|
||||||
|
|
||||||
|
export type UIEnvelope =
|
||||||
|
| {
|
||||||
|
kind: "registered_component";
|
||||||
|
schemaVersion: "agent-ui@1";
|
||||||
|
component: string;
|
||||||
|
surface: UISurface;
|
||||||
|
props: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "chart";
|
||||||
|
schemaVersion: "agent-ui@1";
|
||||||
|
grammar: "echarts-safe-subset";
|
||||||
|
surface: "chat_inline" | "side_panel" | "canvas";
|
||||||
|
spec: unknown;
|
||||||
|
data: unknown;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "map_action";
|
||||||
|
schemaVersion: "agent-ui@1";
|
||||||
|
action: string;
|
||||||
|
surface: "map_overlay";
|
||||||
|
params: unknown;
|
||||||
|
fallbackText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UIEnvelopePayload = {
|
||||||
|
session_id: string;
|
||||||
|
envelope_id: string;
|
||||||
|
created_at: number;
|
||||||
|
envelope: UIEnvelope;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UIRegistry = {
|
||||||
|
schema_version: "agent-ui-registry@1";
|
||||||
|
chart_grammars: string[];
|
||||||
|
components: Array<{ id: string; supportedSurfaces: UISurface[] }>;
|
||||||
|
actions: Array<{ id: string; supportedSurfaces: UISurface[] }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isUiEnvelopeAllowed, parseUiEnvelope, parseUiRegistry } from "./validator";
|
||||||
|
import type { UIRegistry } from "./types";
|
||||||
|
|
||||||
|
const registry: UIRegistry = {
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: ["echarts-safe-subset"],
|
||||||
|
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] }],
|
||||||
|
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UIEnvelope validation", () => {
|
||||||
|
it("fails closed when the backend registry is unavailable", () => {
|
||||||
|
const envelope = parseUiEnvelope({
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "echarts-safe-subset",
|
||||||
|
surface: "chat_inline",
|
||||||
|
spec: { chart_type: "line" },
|
||||||
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
|
});
|
||||||
|
expect(envelope).not.toBeNull();
|
||||||
|
expect(envelope ? isUiEnvelopeAllowed(envelope, null) : true).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects registered components outside the frontend allowlist", () => {
|
||||||
|
const envelope = parseUiEnvelope({
|
||||||
|
kind: "registered_component",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
component: "UnsafePanel",
|
||||||
|
surface: "side_panel",
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects registered components on unsupported surfaces", () => {
|
||||||
|
const envelope = parseUiEnvelope({
|
||||||
|
kind: "registered_component",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
component: "HistoryPanel",
|
||||||
|
surface: "chat_inline",
|
||||||
|
props: { feature_infos: [["J1", "junction"]], data_type: "realtime" }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope).not.toBeNull();
|
||||||
|
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only the echarts-safe-subset chart grammar", () => {
|
||||||
|
expect(
|
||||||
|
parseUiEnvelope({
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "vega-lite",
|
||||||
|
surface: "chat_inline",
|
||||||
|
spec: { chart_type: "line" },
|
||||||
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const envelope = parseUiEnvelope({
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "echarts-safe-subset",
|
||||||
|
surface: "chat_inline",
|
||||||
|
spec: { chart_type: "line" },
|
||||||
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope).not.toBeNull();
|
||||||
|
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported chart types and mismatched data", () => {
|
||||||
|
expect(parseUiEnvelope({
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "echarts-safe-subset",
|
||||||
|
surface: "chat_inline",
|
||||||
|
spec: { chart_type: "pie" },
|
||||||
|
data: { x_data: ["a"], series: [{ name: "s", data: [1] }] }
|
||||||
|
})).toBeNull();
|
||||||
|
expect(parseUiEnvelope({
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "echarts-safe-subset",
|
||||||
|
surface: "chat_inline",
|
||||||
|
spec: { chart_type: "line" },
|
||||||
|
data: { x_data: ["a", "b"], series: [{ name: "s", data: [1] }] }
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters invalid registry surfaces before allowlist checks", () => {
|
||||||
|
const parsed = parseUiRegistry({
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: ["echarts-safe-subset"],
|
||||||
|
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unsafe_surface"] }],
|
||||||
|
actions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.components[0]?.supportedSurfaces).toEqual(["side_panel"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||||
|
import { toTrustedMapAction } from "./map-actions";
|
||||||
|
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
||||||
|
|
||||||
|
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
|
||||||
|
const MAX_ID_LENGTH = 128;
|
||||||
|
const LOCAL_COMPONENT_SURFACES = new Map<string, UISurface[]>([
|
||||||
|
["HistoryPanel", ["side_panel", "canvas"]]
|
||||||
|
]);
|
||||||
|
const LOCAL_ACTION_SURFACES = new Map<string, UISurface[]>([
|
||||||
|
["locate_features", ["map_overlay"]],
|
||||||
|
["zoom_to_map", ["map_overlay"]],
|
||||||
|
["render_junctions", ["map_overlay"]],
|
||||||
|
["apply_layer_style", ["map_overlay"]]
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseUiEnvelope(value.envelope);
|
||||||
|
if (!envelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = boundedString(value.session_id, MAX_ID_LENGTH);
|
||||||
|
const envelopeId = boundedString(value.envelope_id, MAX_ID_LENGTH);
|
||||||
|
if (!sessionId || !envelopeId || !isFiniteTimestamp(value.created_at)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id: sessionId,
|
||||||
|
envelope_id: envelopeId,
|
||||||
|
created_at: value.created_at,
|
||||||
|
envelope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
||||||
|
if (!isRecord(value) || value.schemaVersion !== "agent-ui@1") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.kind === "registered_component") {
|
||||||
|
if (typeof value.component !== "string" || !isSurface(value.surface) || !isValidComponentProps(value.component, value.props)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "registered_component",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
component: value.component,
|
||||||
|
surface: value.surface,
|
||||||
|
props: value.props,
|
||||||
|
data: value.data,
|
||||||
|
fallbackText: optionalString(value.fallbackText)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.kind === "chart") {
|
||||||
|
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface) || !isValidChart(value.spec, value.data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "chart",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
grammar: "echarts-safe-subset",
|
||||||
|
surface: value.surface,
|
||||||
|
spec: value.spec,
|
||||||
|
data: value.data,
|
||||||
|
fallbackText: optionalString(value.fallbackText)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.kind === "map_action") {
|
||||||
|
if (typeof value.action !== "string" || value.surface !== "map_overlay" || !toTrustedMapAction(value.action, value.params)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "map_action",
|
||||||
|
schemaVersion: "agent-ui@1",
|
||||||
|
action: value.action,
|
||||||
|
surface: "map_overlay",
|
||||||
|
params: value.params,
|
||||||
|
fallbackText: optionalString(value.fallbackText)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
|
||||||
|
if (!registry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.kind === "registered_component") {
|
||||||
|
if (!LOCAL_COMPONENT_SURFACES.get(envelope.component)?.includes(envelope.surface)) return false;
|
||||||
|
const manifest = registry.components.find((item) => item.id === envelope.component);
|
||||||
|
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.kind === "chart") {
|
||||||
|
return envelope.grammar === "echarts-safe-subset" && registry.chart_grammars.includes(envelope.grammar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LOCAL_ACTION_SURFACES.get(envelope.action)?.includes(envelope.surface)) return false;
|
||||||
|
const manifest = registry.actions.find((item) => item.id === envelope.action);
|
||||||
|
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUiRegistry(value: unknown): UIRegistry | null {
|
||||||
|
if (!isRecord(value) || value.schema_version !== "agent-ui-registry@1") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: "agent-ui-registry@1",
|
||||||
|
chart_grammars: stringArray(value.chart_grammars),
|
||||||
|
components: manifestArray(value.components),
|
||||||
|
actions: manifestArray(value.actions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
if (!isRecord(item) || typeof item.id !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ id: item.id, supportedSurfaces: stringArray(item.supportedSurfaces).filter(isSurface) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChartSurface(value: unknown): value is "chat_inline" | "side_panel" | "canvas" {
|
||||||
|
return value === "chat_inline" || value === "side_panel" || value === "canvas";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSurface(value: unknown): value is UISurface {
|
||||||
|
return typeof value === "string" && surfaces.has(value as UISurface);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value: unknown, maxLength: number) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteTimestamp(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]) {
|
||||||
|
const keys = new Set(allowed);
|
||||||
|
return Object.keys(value).every((key) => keys.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidComponentProps(component: string, props: unknown) {
|
||||||
|
if (!isRecord(props)) return false;
|
||||||
|
if (component === "HistoryPanel") {
|
||||||
|
if (!hasOnlyKeys(props, ["reason", "feature_infos", "data_type", "start_time", "end_time"])) return false;
|
||||||
|
const items = props.feature_infos;
|
||||||
|
return Array.isArray(items) && items.length > 0 && items.length <= 100 &&
|
||||||
|
items.every((item) => Array.isArray(item) && item.length === 2 && item.every((part) => boundedString(part, 128))) &&
|
||||||
|
(props.data_type === "realtime" || props.data_type === "scheme" || props.data_type === "none");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidChart(spec: unknown, data: unknown) {
|
||||||
|
if (!isRecord(spec) || !hasOnlyKeys(spec, ["title", "chart_type", "x_axis_name", "y_axis_name"])) return false;
|
||||||
|
if (spec.chart_type !== undefined && spec.chart_type !== "line" && spec.chart_type !== "bar") return false;
|
||||||
|
const parsedSpec = parseChartSpec(spec);
|
||||||
|
const parsedData = normalizeSafeChartData(data, 100);
|
||||||
|
return (parsedSpec.chartType === "line" || parsedSpec.chartType === "bar") && parsedData !== null &&
|
||||||
|
parsedData.xData.length <= 100 && parsedData.series.length <= 8 &&
|
||||||
|
parsedData.series.every((series) => series.data.length === parsedData.xData.length);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MapActionRow,
|
||||||
|
MapPanelSection
|
||||||
|
} from "./control-panel";
|
||||||
|
import {
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapAnnotationItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
status?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapAnnotationShareAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapAnnotationPanelProps = {
|
||||||
|
annotations: MapAnnotationItem[];
|
||||||
|
shareActions?: MapAnnotationShareAction[];
|
||||||
|
emptyText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapAnnotationPanel({
|
||||||
|
annotations,
|
||||||
|
shareActions = [],
|
||||||
|
emptyText = "暂无标注"
|
||||||
|
}: MapAnnotationPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MapPanelSection title="标注列表">
|
||||||
|
{annotations.length > 0 ? (
|
||||||
|
annotations.map((annotation) => (
|
||||||
|
<MapActionRow
|
||||||
|
key={annotation.id}
|
||||||
|
icon={annotation.icon ?? getDefaultAnnotationIcon(annotation.status)}
|
||||||
|
label={annotation.label}
|
||||||
|
description={annotation.description}
|
||||||
|
status={annotation.status}
|
||||||
|
selected={annotation.selected}
|
||||||
|
muted={annotation.muted}
|
||||||
|
onClick={annotation.onClick}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
{shareActions.length > 0 ? (
|
||||||
|
<MapPanelSection title="共享">
|
||||||
|
{shareActions.map((action) => (
|
||||||
|
<MapActionRow
|
||||||
|
key={action.id}
|
||||||
|
icon={action.icon ?? getDefaultShareIcon(action.id)}
|
||||||
|
label={action.label}
|
||||||
|
description={action.description}
|
||||||
|
onClick={action.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MapPanelSection>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultAnnotationIcon(status?: string) {
|
||||||
|
if (status) {
|
||||||
|
return ListChecks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultShareIcon(actionId: string) {
|
||||||
|
if (actionId.includes("export") || actionId.includes("download")) {
|
||||||
|
return Download;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionId.includes("share")) {
|
||||||
|
return Share2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Plus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Map } from "lucide-react";
|
||||||
|
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type BaseLayerOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
swatchClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseLayersControlProps = {
|
||||||
|
options: BaseLayerOption[];
|
||||||
|
activeLayerId: string;
|
||||||
|
onSelectLayer: (id: string) => void;
|
||||||
|
title?: string;
|
||||||
|
variant?: "floating" | "panel";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseLayersControl({
|
||||||
|
options,
|
||||||
|
activeLayerId,
|
||||||
|
onSelectLayer,
|
||||||
|
title = "底图",
|
||||||
|
variant = "floating",
|
||||||
|
className = ""
|
||||||
|
}: BaseLayersControlProps) {
|
||||||
|
const activeLayer = options.find((option) => option.id === activeLayerId) ?? options[0];
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearCloseTimer = useCallback(() => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openControl = useCallback(() => {
|
||||||
|
clearCloseTimer();
|
||||||
|
setIsOpen(true);
|
||||||
|
}, [clearCloseTimer]);
|
||||||
|
|
||||||
|
const scheduleClose = useCallback(() => {
|
||||||
|
clearCloseTimer();
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}, 260);
|
||||||
|
}, [clearCloseTimer]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(event: FocusEvent<HTMLElement>) => {
|
||||||
|
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleClose();
|
||||||
|
},
|
||||||
|
[scheduleClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
||||||
|
|
||||||
|
if (variant === "panel") {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto w-[276px] p-3",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
|
<Map size={17} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||||
|
当前 {activeLayer?.label ?? "--"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.id === activeLayerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
disabled={option.disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onSelectLayer(option.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
active
|
||||||
|
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
|
||||||
|
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||||
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BaseLayerSwatch option={option} active={active} />
|
||||||
|
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
onMouseEnter={openControl}
|
||||||
|
onMouseLeave={scheduleClose}
|
||||||
|
onFocus={openControl}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto relative flex h-[96px] items-end justify-end",
|
||||||
|
isOpen ? "w-[384px]" : "w-[96px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 right-[104px] grid h-[96px] grid-cols-[repeat(3,84px)] gap-2 p-1.5 transition-opacity duration-150",
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.id === activeLayerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
disabled={option.disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onSelectLayer(option.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||||
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BaseLayerSwatch option={option} active={active} />
|
||||||
|
<span
|
||||||
|
data-base-layer-label="true"
|
||||||
|
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
|
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
|
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
|
{activeLayer ? (
|
||||||
|
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
|
||||||
|
) : (
|
||||||
|
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
)}
|
||||||
|
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800">
|
||||||
|
{activeLayer?.label ?? title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseLayerSwatchProps = {
|
||||||
|
option: BaseLayerOption;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
option.swatchClassName ?? "bg-slate-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
|
||||||
|
<Check size={10} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapControlPanelProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
widthClassName?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapControlPanel({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
widthClassName = "w-[292px]",
|
||||||
|
visible = true,
|
||||||
|
children
|
||||||
|
}: MapControlPanelProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
aria-hidden={!visible}
|
||||||
|
className={cn(
|
||||||
|
"relative origin-top-right p-3 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
|
||||||
|
visible
|
||||||
|
? "pointer-events-auto opacity-100 translate-x-0 scale-100 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-right-2 motion-safe:zoom-in-95 motion-safe:duration-150 motion-safe:ease-out"
|
||||||
|
: "pointer-events-none translate-x-2 scale-[0.98] opacity-0",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||||
|
widthClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||||
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
|
<Icon size={17} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
|
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
|
||||||
|
<div className="space-y-1.5">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapModeButtonProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapModeButton({ icon: Icon, label, active = false, disabled = false, onClick }: MapModeButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
active ? "bg-blue-600 text-white" : "surface-control text-slate-600 hover:text-blue-700",
|
||||||
|
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={17} aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapActionChip({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapActionRowProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
status?: string;
|
||||||
|
strong?: boolean;
|
||||||
|
variant?: "default" | "primary";
|
||||||
|
selected?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapActionRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
strong = false,
|
||||||
|
variant = "default",
|
||||||
|
selected = false,
|
||||||
|
muted = false,
|
||||||
|
disabled = false,
|
||||||
|
tone = "default",
|
||||||
|
onClick
|
||||||
|
}: MapActionRowProps) {
|
||||||
|
const isDanger = tone === "danger";
|
||||||
|
const isPrimary = variant === "primary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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]",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
isPrimary
|
||||||
|
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
||||||
|
: strong
|
||||||
|
? "border-blue-100 bg-blue-50/95 text-blue-800"
|
||||||
|
: "surface-control border-transparent text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||||
|
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||||
|
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
|
||||||
|
muted && "opacity-70",
|
||||||
|
disabled &&
|
||||||
|
(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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
isPrimary
|
||||||
|
? "bg-white/16 text-white"
|
||||||
|
: selected || strong
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-slate-100 text-slate-500",
|
||||||
|
isDanger && "bg-red-50 text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-semibold">{label}</span>
|
||||||
|
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
|
||||||
|
</span>
|
||||||
|
{status ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||||
|
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapMetricTile({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/shared/ui/tooltip";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapDrawTool = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapDrawToolbarProps = {
|
||||||
|
items: MapDrawTool[];
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
variant?: "compact" | "panel";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapDrawToolbar({ items, label = "绘制工具", className = "", variant = "compact" }: MapDrawToolbarProps) {
|
||||||
|
if (variant === "panel") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn("pointer-events-auto grid grid-cols-2 gap-1.5", className)}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isDanger = item.tone === "danger";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={item.active}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
: "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",
|
||||||
|
item.disabled &&
|
||||||
|
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-7 w-7 shrink-0 place-items-center",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
|
||||||
|
isDanger && "bg-red-50 text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={180}>
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto inline-flex p-1",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isDanger = item.tone === "danger";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-pressed={item.active}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
|
||||||
|
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-sm"
|
||||||
|
: "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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
sideOffset={7}
|
||||||
|
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapLayerControlItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
visible: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapLayerControlProps = {
|
||||||
|
title?: string;
|
||||||
|
items: MapLayerControlItem[];
|
||||||
|
onToggleLayer: (id: string, visible: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapLayerControl({
|
||||||
|
title = "图层",
|
||||||
|
items,
|
||||||
|
onToggleLayer,
|
||||||
|
className = ""
|
||||||
|
}: MapLayerControlProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto w-[276px] p-3",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
|
<Layers3 size={17} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{items.filter((item) => item.visible).length}/{items.length} 可见
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.visible ? Eye : EyeOff;
|
||||||
|
const disabled = item.disabled || item.locked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={item.visible}
|
||||||
|
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
item.visible
|
||||||
|
? "surface-control border-blue-100 text-slate-900"
|
||||||
|
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||||
|
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<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>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 rounded-full",
|
||||||
|
item.visible ? "bg-blue-600" : "bg-slate-300"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BaseLayerOption } from "./base-layers-control";
|
||||||
|
import type { MapLayerControlItem } from "./layer-control";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapLayerPanelProps = {
|
||||||
|
layerItems: MapLayerControlItem[];
|
||||||
|
baseLayerOptions: BaseLayerOption[];
|
||||||
|
activeBaseLayerId: string;
|
||||||
|
onSelectBaseLayer: (id: string) => void;
|
||||||
|
onToggleLayer: (id: string, visible: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapLayerPanel({
|
||||||
|
layerItems,
|
||||||
|
baseLayerOptions,
|
||||||
|
activeBaseLayerId,
|
||||||
|
onSelectBaseLayer,
|
||||||
|
onToggleLayer
|
||||||
|
}: MapLayerPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LayerVisibilitySection items={layerItems} onToggleLayer={onToggleLayer} />
|
||||||
|
<BaseLayerSection
|
||||||
|
options={baseLayerOptions}
|
||||||
|
activeLayerId={activeBaseLayerId}
|
||||||
|
onSelectLayer={onSelectBaseLayer}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayerVisibilitySectionProps = {
|
||||||
|
items: MapLayerControlItem[];
|
||||||
|
onToggleLayer: (id: string, visible: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.visible ? Eye : EyeOff;
|
||||||
|
const disabled = item.disabled || item.locked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={item.visible}
|
||||||
|
aria-label={`${item.label}${item.description ? `,${item.description}` : ""}`}
|
||||||
|
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]",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
item.visible
|
||||||
|
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
|
||||||
|
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||||
|
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center transition-colors",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<span className="block w-full truncate text-xs font-semibold leading-tight">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseLayerSectionProps = {
|
||||||
|
options: BaseLayerOption[];
|
||||||
|
activeLayerId: string;
|
||||||
|
onSelectLayer: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSectionProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||||
|
<span className="text-xs font-semibold text-slate-500">底图</span>
|
||||||
|
<Map size={14} aria-hidden="true" className="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.id === activeLayerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
disabled={option.disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onSelectLayer(option.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
active
|
||||||
|
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
|
||||||
|
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||||
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BaseLayerSwatch option={option} active={active} />
|
||||||
|
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseLayerSwatchProps = {
|
||||||
|
option: BaseLayerOption;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
option.swatchClassName ?? "bg-slate-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
|
||||||
|
<Check size={10} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapLegendItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
color: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
imageSrc?: string;
|
||||||
|
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapLegendProps = {
|
||||||
|
title?: string;
|
||||||
|
items: MapLegendItem[];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapLegend({ title = "图例", items, className = "" }: MapLegendProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto w-[236px] p-3",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||||
|
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
|
||||||
|
<MapLegendList items={items} className="mt-3" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapLegendListProps = {
|
||||||
|
items: MapLegendItem[];
|
||||||
|
className?: string;
|
||||||
|
showStatusDot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
|
||||||
|
return (
|
||||||
|
<ul className={cn("space-y-1.5", className)}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||||
|
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<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}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
|
||||||
|
if (shape === "line") {
|
||||||
|
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape === "dashed-line" || shape === "dash-dot-line") {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden="true">
|
||||||
|
<line x1="1" y1="4" x2="23" y2="4" stroke={color} strokeWidth="3" strokeDasharray={shape === "dashed-line" ? "5 3" : "8 3 2 3"} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape === "square") {
|
||||||
|
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape === "ring") {
|
||||||
|
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
|
||||||
|
|
||||||
|
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
|
||||||
|
|
||||||
|
export const MAP_READABLE_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
|
||||||
|
|
||||||
|
export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
|
||||||
|
|
||||||
|
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
||||||
|
|
||||||
|
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
||||||
|
"acrylic-control border";
|
||||||
|
|
||||||
|
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
|
||||||
|
"acrylic-panel border";
|
||||||
|
|
||||||
|
export const MAP_FOCUS_SURFACE_CLASS_NAME =
|
||||||
|
"acrylic-panel border";
|
||||||
|
|
||||||
|
export const MAP_READABLE_SURFACE_CLASS_NAME =
|
||||||
|
"surface-well border";
|
||||||
|
|
||||||
|
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";
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Copy, Ruler, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MapActionRow,
|
||||||
|
MapModeButton,
|
||||||
|
MapPanelSection
|
||||||
|
} from "./control-panel";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapMeasureMode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapMeasureUnit = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapMeasureResultMetric = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapMeasureAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
strong?: boolean;
|
||||||
|
variant?: "default" | "primary";
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapMeasurePanelProps = {
|
||||||
|
modes: MapMeasureMode[];
|
||||||
|
activeModeId: string;
|
||||||
|
resultLabel: string;
|
||||||
|
resultValue: string;
|
||||||
|
resultUnit: string;
|
||||||
|
metrics?: MapMeasureResultMetric[];
|
||||||
|
units: MapMeasureUnit[];
|
||||||
|
activeUnitId: string;
|
||||||
|
actions: MapMeasureAction[];
|
||||||
|
onSelectMode?: (id: string) => void;
|
||||||
|
onSelectUnit?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapMeasurePanel({
|
||||||
|
modes,
|
||||||
|
activeModeId,
|
||||||
|
resultLabel,
|
||||||
|
resultValue,
|
||||||
|
resultUnit,
|
||||||
|
metrics = [],
|
||||||
|
units,
|
||||||
|
activeUnitId,
|
||||||
|
actions,
|
||||||
|
onSelectMode,
|
||||||
|
onSelectUnit
|
||||||
|
}: MapMeasurePanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MapPanelSection title="模式">
|
||||||
|
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MapModeButton
|
||||||
|
key={mode.id}
|
||||||
|
icon={mode.icon}
|
||||||
|
label={mode.label}
|
||||||
|
active={mode.id === activeModeId}
|
||||||
|
disabled={mode.disabled}
|
||||||
|
onClick={() => onSelectMode?.(mode.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
<MapPanelSection title="结果">
|
||||||
|
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||||
|
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||||
|
<div className="min-w-0 border-b border-slate-100 pb-3">
|
||||||
|
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
|
||||||
|
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
|
||||||
|
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
|
||||||
|
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{metrics.length > 0 ? (
|
||||||
|
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
<div className="truncate text-xs text-slate-500">{metric.label}</div>
|
||||||
|
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
|
||||||
|
{units.map((unit) => (
|
||||||
|
<button
|
||||||
|
key={unit.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectUnit?.(unit.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 text-xs font-semibold transition",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
unit.id === activeUnitId
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "surface-control text-slate-600 hover:text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{unit.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
<MapPanelSection title="操作">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<MapActionRow
|
||||||
|
key={action.id}
|
||||||
|
icon={action.icon ?? getDefaultActionIcon(action.id)}
|
||||||
|
label={action.label}
|
||||||
|
description={action.description}
|
||||||
|
strong={action.strong}
|
||||||
|
variant={action.variant}
|
||||||
|
tone={action.tone}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={action.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MapPanelSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultActionIcon(actionId: string) {
|
||||||
|
if (actionId.includes("copy")) {
|
||||||
|
return Copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionId.includes("clear") || actionId.includes("delete")) {
|
||||||
|
return Trash2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ruler;
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast, Toaster } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
||||||
|
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
|
|
||||||
|
export type MapNoticeOptions = {
|
||||||
|
id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
tone?: MapNoticeTone;
|
||||||
|
duration?: number;
|
||||||
|
position?: MapNoticePosition;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
dismissible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||||
|
|
||||||
|
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||||
|
|
||||||
|
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||||
|
"top-center": "!left-1/2 !right-auto top-[84px] [translate:-50%_0]",
|
||||||
|
"top-right": "top-[84px] right-[72px]",
|
||||||
|
"bottom-left": "bottom-12 left-4",
|
||||||
|
"bottom-right": "bottom-12 right-4"
|
||||||
|
};
|
||||||
|
|
||||||
|
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||||
|
"top-center": "top-center",
|
||||||
|
"top-right": "top-right",
|
||||||
|
"bottom-left": "bottom-left",
|
||||||
|
"bottom-right": "bottom-right"
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapToaster() {
|
||||||
|
return (
|
||||||
|
<Toaster
|
||||||
|
closeButton={false}
|
||||||
|
expand
|
||||||
|
visibleToasts={4}
|
||||||
|
position={sonnerPositions[DEFAULT_POSITION]}
|
||||||
|
toastOptions={{
|
||||||
|
unstyled: true,
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showMapNotice(options: MapNoticeOptions) {
|
||||||
|
const tone = options.tone ?? "info";
|
||||||
|
const position = options.position ?? DEFAULT_POSITION;
|
||||||
|
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||||
|
const dismissible = options.dismissible ?? tone !== "loading";
|
||||||
|
|
||||||
|
return toast.custom(
|
||||||
|
() => <MapNoticeContent {...options} tone={tone} />,
|
||||||
|
{
|
||||||
|
id: options.id,
|
||||||
|
duration,
|
||||||
|
position: sonnerPositions[position],
|
||||||
|
dismissible,
|
||||||
|
cancel: dismissible
|
||||||
|
? {
|
||||||
|
label: <MapNoticeCloseIcon />,
|
||||||
|
onClick: (event) => event.stopPropagation()
|
||||||
|
}
|
||||||
|
: 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-none focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
|
)
|
||||||
|
},
|
||||||
|
className: cn("!fixed !z-[60] !w-[min(420px,calc(100vw-24px))]", positionClassNames[position])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissMapNotice(id?: string | number) {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||||
|
useEffect(() => {
|
||||||
|
showMapNotice({
|
||||||
|
id,
|
||||||
|
tone: "error",
|
||||||
|
title: "地图异常",
|
||||||
|
message,
|
||||||
|
duration: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => dismissMapNotice(id);
|
||||||
|
}, [id, message]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapLoadingNotice({
|
||||||
|
message = "正在初始化地图...",
|
||||||
|
title = "地图加载中",
|
||||||
|
id = "map-loading"
|
||||||
|
}: {
|
||||||
|
message?: string;
|
||||||
|
title?: string;
|
||||||
|
id?: string | number;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
showMapNotice({
|
||||||
|
id,
|
||||||
|
tone: "loading",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration: Infinity,
|
||||||
|
dismissible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => dismissMapNotice(id);
|
||||||
|
}, [id, message, title]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapSourceStatus = "online" | "degraded" | "offline";
|
||||||
|
|
||||||
|
export type MapSourceStatusNoticeProps = {
|
||||||
|
sourceName: string;
|
||||||
|
status: MapSourceStatus;
|
||||||
|
message?: string;
|
||||||
|
id?: string | number;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapSourceStatusNotice({
|
||||||
|
sourceName,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
id = `map-source-${sourceName}`,
|
||||||
|
actionLabel,
|
||||||
|
onAction
|
||||||
|
}: MapSourceStatusNoticeProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
showMapNotice({
|
||||||
|
id,
|
||||||
|
tone: getSourceStatusTone(status),
|
||||||
|
title: getSourceStatusTitle(sourceName, status),
|
||||||
|
message: message ?? getSourceStatusMessage(status),
|
||||||
|
duration: status === "online" ? 2600 : Infinity,
|
||||||
|
actionLabel,
|
||||||
|
onAction
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => dismissMapNotice(id);
|
||||||
|
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeContent({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
tone = "info",
|
||||||
|
actionLabel,
|
||||||
|
onAction
|
||||||
|
}: MapNoticeOptions) {
|
||||||
|
const Icon = getNoticeIcon(tone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapNoticeFrame tone={tone}>
|
||||||
|
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||||
|
<MapNoticeBody
|
||||||
|
title={title}
|
||||||
|
message={message}
|
||||||
|
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||||
|
/>
|
||||||
|
</MapNoticeFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
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> }) {
|
||||||
|
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]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeBody({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeCloseIcon() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="sr-only">关闭通知</span>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoticeIcon(tone: MapNoticeTone) {
|
||||||
|
if (tone === "success") {
|
||||||
|
return CheckCircle2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "warning") {
|
||||||
|
return AlertTriangle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "error") {
|
||||||
|
return XCircle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "loading") {
|
||||||
|
return Loader2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Info;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||||
|
if (status === "online") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "degraded") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
|
||||||
|
if (status === "online") {
|
||||||
|
return `${sourceName} 已连接`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "degraded") {
|
||||||
|
return `${sourceName} 降级运行`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sourceName} 不可用`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceStatusMessage(status: MapSourceStatus) {
|
||||||
|
if (status === "online") {
|
||||||
|
return "地图数据源已恢复正常。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "degraded") {
|
||||||
|
return "部分地图能力不可用,系统已使用可用替代方案。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "地图数据源请求失败,请检查网络或稍后重试。";
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||||
|
|
||||||
|
type ScaleLineState = {
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
zoom: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapScaleLineProps = {
|
||||||
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
|
mapReady: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
coordinatePrecision?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CoordinateState = {
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SCALE: ScaleLineState = {
|
||||||
|
label: "500 m",
|
||||||
|
width: 80,
|
||||||
|
zoom: "Z --"
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_SCALE_WIDTH = 34;
|
||||||
|
const COORDINATE_READOUT_WIDTH_CLASS_NAME = "w-[160px]";
|
||||||
|
|
||||||
|
export function MapScaleLine({
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
maxWidth = 96,
|
||||||
|
coordinatePrecision = 5,
|
||||||
|
className = ""
|
||||||
|
}: MapScaleLineProps) {
|
||||||
|
const [scale, setScale] = useState<ScaleLineState>(DEFAULT_SCALE);
|
||||||
|
const [coordinate, setCoordinate] = useState<CoordinateState | null>(null);
|
||||||
|
const coordinateReadout = coordinate
|
||||||
|
? createCoordinateReadout(coordinate, coordinatePrecision)
|
||||||
|
: null;
|
||||||
|
const coordinateLabel = coordinateReadout
|
||||||
|
? `${coordinateReadout.mercatorText} · ${coordinateReadout.lngLatText}`
|
||||||
|
: "移动鼠标读取坐标";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateScale = () => {
|
||||||
|
setScale(createScaleLineState(map, maxWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateScale();
|
||||||
|
map.on("move", updateScale);
|
||||||
|
map.on("zoom", updateScale);
|
||||||
|
map.on("resize", updateScale);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("move", updateScale);
|
||||||
|
map.off("zoom", updateScale);
|
||||||
|
map.off("resize", updateScale);
|
||||||
|
};
|
||||||
|
}, [mapReady, mapRef, maxWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (event: { lngLat: { lng: number; lat: number } }) => {
|
||||||
|
setCoordinate({
|
||||||
|
lng: event.lngLat.lng,
|
||||||
|
lat: event.lngLat.lat
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = () => {
|
||||||
|
setCoordinate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousemove", handleMove);
|
||||||
|
map.on("mouseout", handleLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("mousemove", handleMove);
|
||||||
|
map.off("mouseout", handleLeave);
|
||||||
|
};
|
||||||
|
}, [mapReady, mapRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={`比例尺 ${scale.label},${coordinateLabel}`}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="min-w-11 whitespace-nowrap text-right tabular-nums">{scale.label}</span>
|
||||||
|
<div
|
||||||
|
className="relative h-2 shrink-0 border-x-2 border-b-2 border-slate-800 transition-[width,border-color] duration-200 ease-out motion-reduce:transition-none"
|
||||||
|
style={{ width: `${scale.width}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScaleLineDivider />
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
|
||||||
|
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
||||||
|
<span className="text-slate-600">EPSG:3857</span>
|
||||||
|
{coordinateReadout ? (
|
||||||
|
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
||||||
|
{coordinateReadout.mercatorText}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-500">Mapbox/OSM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScaleLineDivider() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-3.5 w-px shrink-0 bg-slate-300/70"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScaleLineState(map: MapLibreMap, maxWidth: number): ScaleLineState {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
const canvasWidth = canvas.clientWidth || canvas.width;
|
||||||
|
const center = map.project(map.getCenter());
|
||||||
|
const halfWidth = Math.min(maxWidth / 2, Math.max(1, canvasWidth / 2));
|
||||||
|
const y = Math.round(center.y);
|
||||||
|
const start = map.unproject([Math.max(0, center.x - halfWidth), y]);
|
||||||
|
const end = map.unproject([Math.min(canvasWidth, center.x + halfWidth), y]);
|
||||||
|
const measuredWidth = Math.max(1, Math.min(canvasWidth, center.x + halfWidth) - Math.max(0, center.x - halfWidth));
|
||||||
|
const metersPerMaxWidth = distanceInMeters(start.lng, start.lat, end.lng, end.lat);
|
||||||
|
if (!Number.isFinite(metersPerMaxWidth) || metersPerMaxWidth <= 0) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SCALE,
|
||||||
|
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const niceMeters = getNiceDistance(metersPerMaxWidth);
|
||||||
|
const widthPx = Math.min(
|
||||||
|
measuredWidth,
|
||||||
|
Math.max(Math.min(MIN_SCALE_WIDTH, measuredWidth), Math.round((niceMeters / metersPerMaxWidth) * measuredWidth))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: formatDistance(niceMeters),
|
||||||
|
width: widthPx,
|
||||||
|
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNiceDistance(maxMeters: number) {
|
||||||
|
const target = Math.max(1, maxMeters);
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(target));
|
||||||
|
const normalized = target / magnitude;
|
||||||
|
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
|
||||||
|
|
||||||
|
return multiplier * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(meters: number) {
|
||||||
|
if (meters >= 1000) {
|
||||||
|
return `${Number((meters / 1000).toFixed(meters >= 10000 ? 0 : 1))} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(meters)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCoordinateReadout(coordinate: CoordinateState, precision: number) {
|
||||||
|
return {
|
||||||
|
lngLatText: `Lng ${coordinate.lng.toFixed(precision)} Lat ${coordinate.lat.toFixed(precision)}`,
|
||||||
|
mercatorText: formatWebMercator(coordinate.lng, coordinate.lat)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWebMercator(lng: number, lat: number) {
|
||||||
|
const earthRadius = 6378137;
|
||||||
|
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
||||||
|
const x = earthRadius * toRadians(lng);
|
||||||
|
const y = earthRadius * Math.log(Math.tan(Math.PI / 4 + toRadians(clampedLat) / 2));
|
||||||
|
|
||||||
|
return `X ${Math.round(x).toLocaleString("en-US")} Y ${Math.round(y).toLocaleString("en-US")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceInMeters(startLng: number, startLat: number, endLng: number, endLat: number) {
|
||||||
|
const earthRadius = 6371008.8;
|
||||||
|
const startPhi = toRadians(startLat);
|
||||||
|
const endPhi = toRadians(endLat);
|
||||||
|
const deltaPhi = toRadians(endLat - startLat);
|
||||||
|
const deltaLambda = toRadians(endLng - startLng);
|
||||||
|
const a =
|
||||||
|
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||||
|
Math.cos(startPhi) * Math.cos(endPhi) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||||
|
|
||||||
|
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(degrees: number) {
|
||||||
|
return (degrees * Math.PI) / 180;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/shared/ui/tooltip";
|
||||||
|
import {
|
||||||
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
export type MapToolbarItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
badge?: string | number;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapToolbarProps = {
|
||||||
|
items: MapToolbarItem[];
|
||||||
|
label?: string;
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapToolbar({
|
||||||
|
items,
|
||||||
|
label = "地图工具",
|
||||||
|
orientation = "vertical",
|
||||||
|
className = ""
|
||||||
|
}: MapToolbarProps) {
|
||||||
|
const isVertical = orientation === "vertical";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={180}>
|
||||||
|
<nav
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto p-[3px]",
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||||
|
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((tool, index) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
const title = tool.description ? `${tool.label}:${tool.description}` : tool.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tool.id} className={cn(isVertical ? "contents" : "flex items-center")}>
|
||||||
|
{index > 0 ? <ToolbarDivider vertical={isVertical} /> : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={title}
|
||||||
|
aria-pressed={tool.active}
|
||||||
|
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",
|
||||||
|
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"
|
||||||
|
: 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{tool.badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side={isVertical ? "left" : "top"}
|
||||||
|
sideOffset={8}
|
||||||
|
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarDivider({ vertical }: { vertical: boolean }) {
|
||||||
|
if (vertical) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="my-0.5 flex h-px w-full items-center justify-center"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="block h-px w-3.5 bg-slate-300/70" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-0.5 flex h-full w-px items-center justify-center"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="block h-3.5 w-px bg-slate-300/70" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
|
||||||
|
import { type RefObject, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/shared/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
|
||||||
|
type MapZoomProps = {
|
||||||
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
|
mapReady: boolean;
|
||||||
|
onHome: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
mapRef.current?.zoomIn({ duration: 260 });
|
||||||
|
}, [mapRef]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
mapRef.current?.zoomOut({ duration: 260 });
|
||||||
|
}, [mapRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={180}>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="地图缩放"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex w-[46px] flex-col gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col p-[3px]",
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ControlButton
|
||||||
|
icon={Plus}
|
||||||
|
label="放大"
|
||||||
|
disabled={!mapReady}
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<ControlButton
|
||||||
|
icon={Minus}
|
||||||
|
label="缩小"
|
||||||
|
disabled={!mapReady}
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-[3px]",
|
||||||
|
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ControlButton
|
||||||
|
icon={Home}
|
||||||
|
label="缩放到全局"
|
||||||
|
disabled={!mapReady}
|
||||||
|
onClick={onHome}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlButtonProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={8}
|
||||||
|
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
getRunningWorkflowDefinition,
|
||||||
|
getRunningWorkflowState
|
||||||
|
} from "../data/running-workflows";
|
||||||
|
import type {
|
||||||
|
ScheduledConditionRecord
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export type ConditionStepTickerItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionStepTickerView = {
|
||||||
|
title: string;
|
||||||
|
progressLabel: string;
|
||||||
|
progressValue: number;
|
||||||
|
currentStep: ConditionStepTickerItem | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TickerStackCard = {
|
||||||
|
condition: ScheduledConditionRecord;
|
||||||
|
view: ConditionStepTickerView;
|
||||||
|
stackIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TICKER_STACK_DEPTH = 3;
|
||||||
|
|
||||||
|
export function selectTickerCondition(
|
||||||
|
runningConditions: ScheduledConditionRecord[],
|
||||||
|
rotationIndex: number
|
||||||
|
): ScheduledConditionRecord | null {
|
||||||
|
if (runningConditions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runningConditions[rotationIndex % runningConditions.length] ?? runningConditions[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVisibleTickerCards(
|
||||||
|
runningConditions: ScheduledConditionRecord[],
|
||||||
|
rotationIndex: number,
|
||||||
|
nowMs: number
|
||||||
|
): TickerStackCard[] {
|
||||||
|
if (runningConditions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = Math.min(runningConditions.length, TICKER_STACK_DEPTH);
|
||||||
|
|
||||||
|
return Array.from({ length: visibleCount }, (_, stackIndex) => {
|
||||||
|
const conditionIndex = (rotationIndex + stackIndex) % runningConditions.length;
|
||||||
|
const condition = runningConditions[conditionIndex] ?? runningConditions[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
condition,
|
||||||
|
view: createConditionStepView(condition, nowMs, runningConditions.length),
|
||||||
|
stackIndex
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConditionStepView(
|
||||||
|
condition: ScheduledConditionRecord,
|
||||||
|
nowMs: number,
|
||||||
|
runningConditionCount: number
|
||||||
|
): ConditionStepTickerView {
|
||||||
|
const workflow = getRunningWorkflowDefinition(condition);
|
||||||
|
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
|
||||||
|
const currentStep = workflow.steps[workflowState.currentStepIndex];
|
||||||
|
const currentStepItem: ConditionStepTickerItem | null = currentStep
|
||||||
|
? {
|
||||||
|
id: currentStep.id,
|
||||||
|
title: `步骤 ${workflowState.currentStepIndex + 1}/${workflow.steps.length} ${currentStep.title}`,
|
||||||
|
detail: currentStep.detail
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: runningConditionCount > 1
|
||||||
|
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
|
||||||
|
: `${workflow.title} · ${condition.title}`,
|
||||||
|
progressLabel: `${workflowState.progress}%`,
|
||||||
|
progressValue: workflowState.progress,
|
||||||
|
currentStep: currentStepItem
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
|
||||||
|
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
|
||||||
|
|
||||||
|
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: "condition",
|
||||||
|
taskId,
|
||||||
|
scheduledAt: "2026-07-08T10:00:00+08:00",
|
||||||
|
title: id,
|
||||||
|
summary: "Running condition",
|
||||||
|
status: "running",
|
||||||
|
riskLevel: "normal",
|
||||||
|
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
|
||||||
|
sessionId: id,
|
||||||
|
durationMinutes: 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createVisibleTickerCards", () => {
|
||||||
|
it("wraps the final running condition back to the first cards", () => {
|
||||||
|
const conditions = [
|
||||||
|
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||||
|
createRunningCondition("condition-b", "smart-dispatch"),
|
||||||
|
createRunningCondition("condition-c", "pump-energy"),
|
||||||
|
createRunningCondition("condition-d", "network-simulation")
|
||||||
|
];
|
||||||
|
|
||||||
|
const cards = createVisibleTickerCards(
|
||||||
|
conditions,
|
||||||
|
conditions.length - 1,
|
||||||
|
Date.parse("2026-07-08T10:01:00+08:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cards).toHaveLength(3);
|
||||||
|
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||||
|
"condition-d",
|
||||||
|
"condition-a",
|
||||||
|
"condition-b"
|
||||||
|
]);
|
||||||
|
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate the same task to fill stack slots", () => {
|
||||||
|
const conditions = [
|
||||||
|
createRunningCondition("condition-a", "scada-diagnosis")
|
||||||
|
];
|
||||||
|
|
||||||
|
const cards = createVisibleTickerCards(
|
||||||
|
conditions,
|
||||||
|
0,
|
||||||
|
Date.parse("2026-07-08T10:01:00+08:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards.map((card) => card.condition.id)).toEqual(["condition-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses two stack slots for two running tasks", () => {
|
||||||
|
const conditions = [
|
||||||
|
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||||
|
createRunningCondition("condition-b", "smart-dispatch")
|
||||||
|
];
|
||||||
|
|
||||||
|
const cards = createVisibleTickerCards(
|
||||||
|
conditions,
|
||||||
|
1,
|
||||||
|
Date.parse("2026-07-08T10:01:00+08:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||||
|
"condition-b",
|
||||||
|
"condition-a"
|
||||||
|
]);
|
||||||
|
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
ScheduledConditionRecord
|
||||||
|
} from "@/features/workbench/types";
|
||||||
|
import {
|
||||||
|
createVisibleTickerCards,
|
||||||
|
type TickerStackCard
|
||||||
|
} from "./agent-task-ticker-utils";
|
||||||
|
|
||||||
|
type AgentTaskTickerProps = {
|
||||||
|
conditions: ScheduledConditionRecord[];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TickerTransitionDirection = -1 | 1;
|
||||||
|
|
||||||
|
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||||
|
const tickerSwitchAnimationMs = 380;
|
||||||
|
const tickerCardVariants: Variants = {
|
||||||
|
initial: (direction: TickerTransitionDirection) => ({
|
||||||
|
opacity: 0.62,
|
||||||
|
x: 0,
|
||||||
|
y: direction > 0 ? 36 : -28,
|
||||||
|
rotate: 0
|
||||||
|
}),
|
||||||
|
exit: (direction: TickerTransitionDirection) => ({
|
||||||
|
opacity: [1, 1, 0],
|
||||||
|
x: 0,
|
||||||
|
y: direction > 0 ? 36 : -28,
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
times: [0, 0.65, 1],
|
||||||
|
ease: [0.5, 0, 0.2, 1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickerContentVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
clipPath: "inset(0 100% 0 0)"
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
clipPath: "inset(0 0% 0 0)",
|
||||||
|
transition: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: tickerEase
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
clipPath: "inset(0 0 0 100%)",
|
||||||
|
transition: {
|
||||||
|
duration: 0.14,
|
||||||
|
ease: [0.5, 0, 0.2, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickerIndicatorVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
x: 4,
|
||||||
|
scale: 0.96
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.16,
|
||||||
|
ease: tickerEase
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
x: 4,
|
||||||
|
scale: 0.96,
|
||||||
|
transition: {
|
||||||
|
duration: 0.12,
|
||||||
|
ease: [0.5, 0, 0.2, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentTaskTicker({
|
||||||
|
conditions,
|
||||||
|
className
|
||||||
|
}: AgentTaskTickerProps) {
|
||||||
|
const sectionRef = useRef<HTMLElement | null>(null);
|
||||||
|
const isTaskSwitchLockedRef = useRef(false);
|
||||||
|
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const [rotationIndex, setRotationIndex] = useState(0);
|
||||||
|
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
|
||||||
|
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
|
||||||
|
id: 0,
|
||||||
|
active: false
|
||||||
|
}));
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const runningConditions = conditions;
|
||||||
|
const visibleTickerCards = useMemo(
|
||||||
|
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||||
|
[runningConditions, rotationIndex, nowMs]
|
||||||
|
);
|
||||||
|
const hasMultipleRunningTasks = runningConditions.length > 1;
|
||||||
|
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
|
||||||
|
const tickerLabel = hasMultipleRunningTasks
|
||||||
|
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
|
||||||
|
: "工况任务步骤";
|
||||||
|
|
||||||
|
const releaseTaskSwitchLock = useCallback(() => {
|
||||||
|
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||||
|
taskSwitchUnlockTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTaskSwitchLockedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finishTaskSwitchAnimation = useCallback(() => {
|
||||||
|
setTaskSwitchAnimation((current) => (
|
||||||
|
current.active ? { ...current, active: false } : current
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lockTaskSwitch = useCallback(() => {
|
||||||
|
isTaskSwitchLockedRef.current = true;
|
||||||
|
|
||||||
|
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
isTaskSwitchLockedRef.current = false;
|
||||||
|
taskSwitchUnlockTimeoutRef.current = null;
|
||||||
|
finishTaskSwitchAnimation();
|
||||||
|
}, tickerSwitchAnimationMs);
|
||||||
|
}, [finishTaskSwitchAnimation]);
|
||||||
|
|
||||||
|
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
|
||||||
|
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lockTaskSwitch();
|
||||||
|
setTransitionDirection(direction);
|
||||||
|
setTaskSwitchAnimation((current) => ({
|
||||||
|
id: current.id + 1,
|
||||||
|
active: true
|
||||||
|
}));
|
||||||
|
setRotationIndex((current) => (
|
||||||
|
(current + direction + runningConditions.length) % runningConditions.length
|
||||||
|
));
|
||||||
|
}, [lockTaskSwitch, runningConditions.length]);
|
||||||
|
|
||||||
|
const showPreviousTask = useCallback(() => {
|
||||||
|
shiftTask(-1);
|
||||||
|
}, [shiftTask]);
|
||||||
|
|
||||||
|
const showNextTask = useCallback(() => {
|
||||||
|
shiftTask(1);
|
||||||
|
}, [shiftTask]);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((event: WheelEvent) => {
|
||||||
|
if (!hasMultipleRunningTasks || event.deltaY === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.deltaY > 0) {
|
||||||
|
showNextTask();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPreviousTask();
|
||||||
|
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}, 1_000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRotationIndex(0);
|
||||||
|
releaseTaskSwitchLock();
|
||||||
|
finishTaskSwitchAnimation();
|
||||||
|
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
|
||||||
|
|
||||||
|
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const section = sectionRef.current;
|
||||||
|
if (!section) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
|
return () => section.removeEventListener("wheel", handleWheel);
|
||||||
|
}, [handleWheel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMultipleRunningTasks || isHovered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
shiftTask(1);
|
||||||
|
}, 6_000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
||||||
|
|
||||||
|
if (visibleTickerCards.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
className={cn(
|
||||||
|
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={tickerLabel}
|
||||||
|
title={tickerLabel}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
||||||
|
{visibleTickerCards
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((card) => (
|
||||||
|
<TickerTaskCard
|
||||||
|
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
|
||||||
|
card={card}
|
||||||
|
isActive={card.stackIndex === 0}
|
||||||
|
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
|
||||||
|
transitionDirection={transitionDirection}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isHovered && hasMultipleRunningTasks ? (
|
||||||
|
<motion.div
|
||||||
|
key="task-indicator"
|
||||||
|
className="agent-task-ticker-indicator surface-control flex flex-col gap-1 rounded-full border px-1 py-1"
|
||||||
|
variants={tickerIndicatorVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
{runningConditions.map((runningCondition, index) => (
|
||||||
|
<span
|
||||||
|
key={runningCondition.id}
|
||||||
|
className={cn(
|
||||||
|
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
|
||||||
|
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TickerTaskCard({
|
||||||
|
card,
|
||||||
|
isActive,
|
||||||
|
animateContentChanges,
|
||||||
|
transitionDirection
|
||||||
|
}: {
|
||||||
|
card: TickerStackCard;
|
||||||
|
isActive: boolean;
|
||||||
|
animateContentChanges: boolean;
|
||||||
|
transitionDirection: TickerTransitionDirection;
|
||||||
|
}) {
|
||||||
|
const { condition, view, stackIndex } = card;
|
||||||
|
const stackStyle = getTickerStackStyle(stackIndex);
|
||||||
|
const contentKey = view.currentStep
|
||||||
|
? `${condition.id}-${view.currentStep.id}`
|
||||||
|
: `${condition.id}-pending`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
layout="position"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||||||
|
isActive ? "glass-transient z-30" : "surface-control pointer-events-none z-10 border"
|
||||||
|
)}
|
||||||
|
style={{ transformOrigin: "center top" }}
|
||||||
|
custom={transitionDirection}
|
||||||
|
variants={tickerCardVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate={{
|
||||||
|
opacity: stackStyle.opacity,
|
||||||
|
x: stackStyle.x,
|
||||||
|
y: stackStyle.y,
|
||||||
|
rotate: stackStyle.rotate,
|
||||||
|
transition: {
|
||||||
|
duration: isActive ? 0.34 : 0.3,
|
||||||
|
ease: tickerEase
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
exit="exit"
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full",
|
||||||
|
isActive ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AnimatePresence key={condition.id} initial={false} mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={contentKey}
|
||||||
|
className="h-full overflow-hidden"
|
||||||
|
variants={tickerContentVariants}
|
||||||
|
initial={animateContentChanges ? "initial" : false}
|
||||||
|
animate={animateContentChanges ? "animate" : undefined}
|
||||||
|
exit={animateContentChanges ? "exit" : undefined}
|
||||||
|
>
|
||||||
|
<TickerTaskCardContent view={view} />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
|
||||||
|
if (!view.currentStep) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
|
||||||
|
<TickerStatusDot progressValue={view.progressValue} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
|
||||||
|
{view.title}
|
||||||
|
</span>
|
||||||
|
<TickerProgressBar progressLabel={view.progressLabel} muted />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
|
||||||
|
<TickerStatusDot progressValue={view.progressValue} />
|
||||||
|
<div className="flex min-w-0 flex-col justify-center">
|
||||||
|
<div className="min-w-0 pr-1">
|
||||||
|
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
|
||||||
|
{view.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0" title={`${view.currentStep.title}:${view.currentStep.detail}`}>
|
||||||
|
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
|
||||||
|
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
|
||||||
|
{view.currentStep.title}
|
||||||
|
</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
|
||||||
|
{view.currentStep.detail}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TickerProgressBar progressLabel={view.progressLabel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TickerProgressBar({
|
||||||
|
progressLabel,
|
||||||
|
muted = false
|
||||||
|
}: {
|
||||||
|
progressLabel: string;
|
||||||
|
muted?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 h-0.5 overflow-hidden rounded-full",
|
||||||
|
muted ? "bg-slate-200/60" : "bg-slate-200/80"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||||
|
animate={{ width: progressLabel }}
|
||||||
|
initial={false}
|
||||||
|
transition={{ duration: 0.34, ease: tickerEase }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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" />
|
||||||
|
<motion.circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="7"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="stroke-blue-600"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTickerStackStyle(stackIndex: number) {
|
||||||
|
if (stackIndex === 1) {
|
||||||
|
return {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 7,
|
||||||
|
rotate: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stackIndex === 2) {
|
||||||
|
return {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 14,
|
||||||
|
rotate: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotate: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||||
|
|
||||||
|
describe("getConditionReportRiskLevel", () => {
|
||||||
|
it("maps error and warning statuses to their operational risk levels", () => {
|
||||||
|
expect(getConditionReportRiskLevel("error", "normal")).toBe("critical");
|
||||||
|
expect(getConditionReportRiskLevel("warning", "normal")).toBe("attention");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the explicit risk level for lifecycle-only statuses", () => {
|
||||||
|
expect(getConditionReportRiskLevel("completed", "normal")).toBe("normal");
|
||||||
|
expect(getConditionReportRiskLevel("completed", "attention")).toBe("attention");
|
||||||
|
expect(getConditionReportRiskLevel("running", "critical")).toBe("critical");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type {
|
||||||
|
ScheduledConditionRiskLevel,
|
||||||
|
ScheduledConditionStatus
|
||||||
|
} from "@/features/workbench/types";
|
||||||
|
|
||||||
|
export function getConditionReportRiskLevel(
|
||||||
|
status: ScheduledConditionStatus,
|
||||||
|
riskLevel: ScheduledConditionRiskLevel
|
||||||
|
): ScheduledConditionRiskLevel {
|
||||||
|
if (status === "error") {
|
||||||
|
return "critical";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "warning") {
|
||||||
|
return "attention";
|
||||||
|
}
|
||||||
|
|
||||||
|
return riskLevel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode, SVGProps } from "react";
|
||||||
|
|
||||||
|
export type DrainageFeatureIconProps = SVGProps<SVGSVGElement> & {
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DrainageIconFrameProps = DrainageFeatureIconProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConduitFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="3.5" cy="12" r="1.75" />
|
||||||
|
<circle cx="20.5" cy="12" r="1.75" />
|
||||||
|
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<circle cx="12" cy="12" r="1.75" />
|
||||||
|
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValveFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M3.5 12h4.75M15.75 12h4.75" />
|
||||||
|
<path d="m8.25 7 7.5 5-7.5 5V7Z" />
|
||||||
|
<path d="m15.75 7-7.5 5 7.5 5V7Z" />
|
||||||
|
<path d="M12 7V3.5M9.5 3.5h5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservoirFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M5 8.5c0-2.2 14-2.2 14 0v7c0 2.2-14 2.2-14 0v-7Z" />
|
||||||
|
<path d="M5 8.5c0 2.2 14 2.2 14 0" />
|
||||||
|
<path d="M5 12c0 2.2 14 2.2 14 0" />
|
||||||
|
<path d="M8 19.5h8" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScadaFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="12" cy="7.5" r="1.75" />
|
||||||
|
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
|
||||||
|
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
|
||||||
|
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M2.5 12H9M15 12h6.5" />
|
||||||
|
<path d="M9.5 4v5.35M9.5 14.65V20M14.5 4v5.35M14.5 14.65V20" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PumpFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="9.5" cy="13.5" r="6" />
|
||||||
|
<circle cx="9.5" cy="13.5" r="1.25" />
|
||||||
|
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
|
||||||
|
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
|
||||||
|
<path d="m17 6 2.5 2.5L22 6" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutfallFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M4 4.5h10v7h5" />
|
||||||
|
<path d="m16.5 8.75 2.75 2.75-2.75 2.75" />
|
||||||
|
<path d="M3 18c1.5-1.25 3-1.25 4.5 0s3 1.25 4.5 0 3-1.25 4.5 0 3 1.25 4.5 0M5 21c1.15-.75 2.3-.75 3.45 0s2.3.75 3.45 0 2.3-.75 3.45 0 2.3.75 3.45 0" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Target, TriangleAlert, X } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { DetailFeature } from "../types";
|
||||||
|
import {
|
||||||
|
getFeatureInsightMetrics,
|
||||||
|
getLocalizedFeatureProperties
|
||||||
|
} from "../utils/feature-properties";
|
||||||
|
import { FeaturePropertyList } from "./feature-property-list";
|
||||||
|
|
||||||
|
type FeatureInsightPanelProps = {
|
||||||
|
feature: DetailFeature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
if (!feature) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
const propertyEntries = useMemo(() => {
|
||||||
|
if (!feature) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLocalizedFeatureProperties(feature.properties);
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||||
|
<div className="border-b border-slate-200/80 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium tracking-[0.14em] text-slate-500">要素详情</p>
|
||||||
|
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
||||||
|
{feature ? feature.title : "选择一个管网对象"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭详情"
|
||||||
|
title="关闭详情"
|
||||||
|
onClick={onClose}
|
||||||
|
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
{feature ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{metrics.map(({ label, value }) => (
|
||||||
|
<div key={label} className="surface-reading rounded border p-3">
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
<TriangleAlert size={16} aria-hidden="true" />
|
||||||
|
Agent 解释
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||||
|
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<FeaturePropertyList entries={propertyEntries} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500">
|
||||||
|
<Target size={22} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||||
|
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
|
||||||
|
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Copy, Database, X } from "lucide-react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
|
} from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
|
import type { DetailFeature } from "../types";
|
||||||
|
import {
|
||||||
|
getFeaturePanelModel,
|
||||||
|
type FeaturePanelBadgeTone
|
||||||
|
} from "../utils/feature-properties";
|
||||||
|
import {
|
||||||
|
ConduitFeatureIcon,
|
||||||
|
JunctionFeatureIcon,
|
||||||
|
PumpFeatureIcon,
|
||||||
|
ReservoirFeatureIcon,
|
||||||
|
ScadaFeatureIcon,
|
||||||
|
ValveFeatureIcon,
|
||||||
|
type DrainageFeatureIconProps
|
||||||
|
} from "./drainage-feature-icons";
|
||||||
|
import { FeaturePropertyList } from "./feature-property-list";
|
||||||
|
|
||||||
|
type FeaturePopoverProps = {
|
||||||
|
feature: DetailFeature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_LAYER_META: Record<
|
||||||
|
WaterNetworkSourceId,
|
||||||
|
{
|
||||||
|
icon: ComponentType<DrainageFeatureIconProps>;
|
||||||
|
label: string;
|
||||||
|
headerClassName: string;
|
||||||
|
labelClassName: string;
|
||||||
|
iconClassName: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
pipes: { icon: ConduitFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
|
||||||
|
junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
|
||||||
|
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" },
|
||||||
|
reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" },
|
||||||
|
scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
|
||||||
|
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" },
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (copyResetTimeoutRef.current) {
|
||||||
|
clearTimeout(copyResetTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id);
|
||||||
|
const layerMeta = FEATURE_LAYER_META[feature.layer];
|
||||||
|
const LayerIcon = layerMeta.icon;
|
||||||
|
const copied = copiedId === panelModel.id;
|
||||||
|
const canCopy = panelModel.id !== "暂无";
|
||||||
|
|
||||||
|
const handleCopyId = async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
throw new Error("Clipboard API unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(panelModel.id);
|
||||||
|
setCopiedId(panelModel.id);
|
||||||
|
|
||||||
|
if (copyResetTimeoutRef.current) {
|
||||||
|
clearTimeout(copyResetTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyResetTimeoutRef.current = setTimeout(() => setCopiedId(null), 1500);
|
||||||
|
} catch {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "error",
|
||||||
|
title: "无法复制编号",
|
||||||
|
message: "请手动选择编号后复制。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
|
||||||
|
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className={cn("px-4 pb-3 pt-4", layerMeta.headerClassName)}>
|
||||||
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||||
|
<span className={cn("grid h-12 w-12 place-items-center rounded-full ring-1", layerMeta.iconClassName)} aria-hidden="true">
|
||||||
|
<LayerIcon size={24} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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" />
|
||||||
|
{panelModel.badge.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">编号 {panelModel.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
|
<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")} />
|
||||||
|
</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-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
{panelModel.attributes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
<Database size={14} aria-hidden="true" />
|
||||||
|
设施属性
|
||||||
|
</div>
|
||||||
|
<FeaturePropertyList entries={panelModel.attributes} variant="popover" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
{copied ? "编号已复制" : ""}
|
||||||
|
</span>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
|
||||||
|
|
||||||
|
type FeaturePropertyListProps = {
|
||||||
|
entries: LocalizedFeatureProperty[];
|
||||||
|
variant?: "insight" | "popover";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeaturePropertyList({
|
||||||
|
entries,
|
||||||
|
variant = "insight"
|
||||||
|
}: FeaturePropertyListProps) {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className={cn(
|
||||||
|
"surface-reading divide-y divide-slate-100 border",
|
||||||
|
variant === "popover" ? "rounded-xl px-3" : "rounded"
|
||||||
|
)}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={cn(
|
||||||
|
"grid",
|
||||||
|
variant === "popover"
|
||||||
|
? "min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5"
|
||||||
|
: "grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-2 text-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className={cn(
|
||||||
|
"text-slate-500",
|
||||||
|
variant === "popover" ? "text-xs font-medium leading-5" : "truncate"
|
||||||
|
)}>
|
||||||
|
{entry.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn(
|
||||||
|
"font-medium text-slate-800",
|
||||||
|
variant === "popover"
|
||||||
|
? "break-words text-right text-sm font-semibold leading-5 tabular-nums"
|
||||||
|
: "truncate",
|
||||||
|
entry.value === "暂无" && "font-medium text-slate-400"
|
||||||
|
)}>
|
||||||
|
{entry.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
type IconButtonProps = {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||||
|
active
|
||||||
|
? "border-slate-800 bg-slate-900 text-white"
|
||||||
|
: "surface-control border-transparent text-slate-700 hover:bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
LAYER_GROUP_GEOMETRIES,
|
||||||
|
type LayerGroupId,
|
||||||
|
type LayerGroupStylePatch
|
||||||
|
} from "../map/layer-group-style";
|
||||||
|
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||||
|
import { getNumericFeatureProperties } from "../map/value-label";
|
||||||
|
import type {
|
||||||
|
FeatureTarget,
|
||||||
|
WorkbenchMapCommands,
|
||||||
|
WorkbenchMapControllerState
|
||||||
|
} from "../map/workbench-map-controller";
|
||||||
|
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, type AvailableWaterNetworkSourceId } from "../map/sources";
|
||||||
|
import type { DetailFeature } from "../types";
|
||||||
|
|
||||||
|
type MapDevPanelProps = {
|
||||||
|
commands: WorkbenchMapCommands;
|
||||||
|
controllerState: WorkbenchMapControllerState;
|
||||||
|
detailFeature: DetailFeature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
||||||
|
pipes: "管线",
|
||||||
|
junctions: "节点",
|
||||||
|
valves: "阀门",
|
||||||
|
reservoirs: "水库",
|
||||||
|
scada: "SCADA"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||||
|
|
||||||
|
const ERROR_LABELS: Record<string, string> = {
|
||||||
|
MAP_NOT_READY: "地图尚未就绪",
|
||||||
|
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||||
|
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||||
|
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||||
|
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
|
||||||
|
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||||
|
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
|
||||||
|
const [property, setProperty] = useState("diameter");
|
||||||
|
const [precision, setPrecision] = useState(0);
|
||||||
|
const [unit, setUnit] = useState("mm");
|
||||||
|
const [groupId, setGroupId] = useState<LayerGroupId>("pipes");
|
||||||
|
const [draft, setDraft] = useState<Record<string, string | number>>({
|
||||||
|
color: MAP_STYLE_TOKENS.supply.pipeMedium,
|
||||||
|
fillColor: MAP_STYLE_TOKENS.supply.junction,
|
||||||
|
strokeColor: "#FFFFFF",
|
||||||
|
opacity: 0.9,
|
||||||
|
widthMultiplier: 1,
|
||||||
|
radiusMultiplier: 1,
|
||||||
|
strokeMultiplier: 1,
|
||||||
|
iconMultiplier: 1,
|
||||||
|
dash: "original"
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
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];
|
||||||
|
|
||||||
|
function useSelectedFeature() {
|
||||||
|
if (!detailFeature) return;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft() {
|
||||||
|
let patch: LayerGroupStylePatch;
|
||||||
|
if (geometry === "line") {
|
||||||
|
patch = {
|
||||||
|
color: String(draft.color),
|
||||||
|
opacity: Number(draft.opacity),
|
||||||
|
widthMultiplier: Number(draft.widthMultiplier),
|
||||||
|
dash: draft.dash as "original" | "solid" | "dashed"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
patch = {
|
||||||
|
fillColor: String(draft.fillColor),
|
||||||
|
strokeColor: String(draft.strokeColor),
|
||||||
|
opacity: Number(draft.opacity),
|
||||||
|
radiusMultiplier: Number(draft.radiusMultiplier),
|
||||||
|
strokeMultiplier: Number(draft.strokeMultiplier),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
commands.applyLayerGroupStyle(groupId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
data-testid="map-dev-panel"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
|
||||||
|
<div>
|
||||||
|
<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}>
|
||||||
|
<X size={16} />
|
||||||
|
</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">
|
||||||
|
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Field>
|
||||||
|
<Field label="Feature ID">
|
||||||
|
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</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>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="全网水流" description="按管渠拓扑方向显示静态或动画流纹。">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
|
||||||
|
<Field label="数值属性">
|
||||||
|
{numericProperties.length ? (
|
||||||
|
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
|
||||||
|
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
|
||||||
|
</select>
|
||||||
|
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
|
||||||
|
</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="覆盖业务组样式,交互语义色保持不变。">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
|
||||||
|
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</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 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-none 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";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Loader2,
|
||||||
|
XCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import type {
|
||||||
|
ScheduledConditionItem,
|
||||||
|
ScheduledConditionStatus,
|
||||||
|
ScheduledWorkOrderItem,
|
||||||
|
ScheduledWorkOrderStage
|
||||||
|
} from "@/features/workbench/types";
|
||||||
|
|
||||||
|
export const CONDITION_FILTER_ALL = "__all__";
|
||||||
|
|
||||||
|
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||||
|
completed: "完成",
|
||||||
|
running: "执行中",
|
||||||
|
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 statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
||||||
|
completed: CheckCircle2,
|
||||||
|
running: Loader2,
|
||||||
|
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 conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
||||||
|
|
||||||
|
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||||
|
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
||||||
|
|
||||||
|
export type ConditionTaskFilterOption = {
|
||||||
|
value: ConditionTaskFilterValue;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionStatusFilterOption = {
|
||||||
|
value: ScheduledConditionStatus;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
|
||||||
|
const recentConditions = conditions
|
||||||
|
.filter((condition) => condition.kind === "condition")
|
||||||
|
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
|
||||||
|
|
||||||
|
const futureWorkOrders = conditions
|
||||||
|
.filter((condition) => condition.kind === "work_order")
|
||||||
|
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
|
||||||
|
|
||||||
|
return { recentConditions, futureWorkOrders };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): ConditionTaskFilterOption[] {
|
||||||
|
const taskCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
conditions.forEach((condition) => {
|
||||||
|
taskCounts.set(condition.title, (taskCounts.get(condition.title) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...taskCounts.entries()]
|
||||||
|
.map(([label, count]) => ({ value: label, label, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "zh-CN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||||
|
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
||||||
|
|
||||||
|
conditions.forEach((condition) => {
|
||||||
|
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return conditionStatusOrder.map((status) => ({
|
||||||
|
value: status,
|
||||||
|
label: statusLabels[status],
|
||||||
|
count: statusCounts.get(status) ?? 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionTime(condition: ScheduledConditionItem) {
|
||||||
|
return Date.parse(condition.scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(value: string) {
|
||||||
|
const match = value.match(/T(\d{2}:\d{2})/);
|
||||||
|
return match?.[1] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
|
||||||
|
const startTime = formatTime(value);
|
||||||
|
if (typeof durationMinutes !== "number") {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
|
||||||
|
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||||
|
return `${startTime} - ${endTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
||||||
|
if (status === "running") {
|
||||||
|
return "执行中";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof durationMinutes !== "number") {
|
||||||
|
return "未记录";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${durationMinutes} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||||
|
if (riskLevel === "critical") {
|
||||||
|
return "高风险";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskLevel === "attention") {
|
||||||
|
return "关注";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "正常";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||||
|
return (
|
||||||
|
condition.stages.find((stage) => stage.status === "current") ??
|
||||||
|
condition.stages.find((stage) => stage.status === "pending") ??
|
||||||
|
condition.stages.at(-1) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getStatusIcon(status: ScheduledConditionStatus) {
|
||||||
|
return statusIcons[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
||||||
|
return statusIconClassNames[status];
|
||||||
|
}
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ClipboardList,
|
||||||
|
History,
|
||||||
|
XCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||||
|
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
||||||
|
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
||||||
|
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/shared/ui/dropdown-menu";
|
||||||
|
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||||
|
import {
|
||||||
|
CONDITION_FILTER_ALL,
|
||||||
|
createStatusFilterOptions,
|
||||||
|
createTaskFilterOptions,
|
||||||
|
formatTime,
|
||||||
|
getConditionTime,
|
||||||
|
getStatusIcon,
|
||||||
|
getStatusIconClassName,
|
||||||
|
groupScheduledConditions,
|
||||||
|
type ConditionStatusFilterOption,
|
||||||
|
type ConditionStatusFilterValue,
|
||||||
|
type ConditionTaskFilterOption,
|
||||||
|
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";
|
||||||
|
|
||||||
|
type ScheduledConditionFeedProps = {
|
||||||
|
conditions?: ScheduledConditionItem[];
|
||||||
|
expanded?: boolean;
|
||||||
|
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||||
|
loading?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
selectedConditionId?: string | null;
|
||||||
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
|
onFocusRequestHandled?: (requestId: number) => void;
|
||||||
|
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||||
|
onExpandAgent: () => void;
|
||||||
|
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||||
|
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScheduledConditionFeedFocusRequest = {
|
||||||
|
conditionId: string;
|
||||||
|
requestId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScheduledConditionFeed({
|
||||||
|
conditions = [],
|
||||||
|
expanded: controlledExpanded,
|
||||||
|
focusRequest,
|
||||||
|
loading = false,
|
||||||
|
visible = true,
|
||||||
|
selectedConditionId: controlledSelectedConditionId,
|
||||||
|
onExpandedChange,
|
||||||
|
onFocusRequestHandled,
|
||||||
|
onSelectedConditionChange,
|
||||||
|
onExpandAgent,
|
||||||
|
onLoadHistorySession,
|
||||||
|
onSubmitPrompt
|
||||||
|
}: ScheduledConditionFeedProps) {
|
||||||
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||||
|
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||||
|
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||||
|
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||||
|
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||||
|
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||||
|
() => groupScheduledConditions(conditions),
|
||||||
|
[conditions]
|
||||||
|
);
|
||||||
|
const allTimelineConditions = useMemo(
|
||||||
|
() => [...allRecentConditions, ...allFutureWorkOrders],
|
||||||
|
[allFutureWorkOrders, allRecentConditions]
|
||||||
|
);
|
||||||
|
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||||
|
const taskFilteredConditions = useMemo(
|
||||||
|
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||||
|
[conditions, taskFilter]
|
||||||
|
);
|
||||||
|
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||||
|
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||||
|
const filteredConditions = useMemo(
|
||||||
|
() =>
|
||||||
|
taskFilteredConditions.filter(
|
||||||
|
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
|
||||||
|
),
|
||||||
|
[statusFilter, taskFilteredConditions]
|
||||||
|
);
|
||||||
|
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||||
|
const timelineConditions = useMemo(
|
||||||
|
() => [...recentConditions, ...futureWorkOrders],
|
||||||
|
[futureWorkOrders, recentConditions]
|
||||||
|
);
|
||||||
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||||
|
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
||||||
|
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
||||||
|
const headerSummary = loading
|
||||||
|
? "正在加载工况任务"
|
||||||
|
: expanded && filterActive
|
||||||
|
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
||||||
|
: expanded
|
||||||
|
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
||||||
|
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`;
|
||||||
|
const selectedCondition =
|
||||||
|
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||||
|
recentConditions[0] ??
|
||||||
|
futureWorkOrders[0] ??
|
||||||
|
null;
|
||||||
|
const focusConditionId = focusRequest?.conditionId;
|
||||||
|
const focusRequestId = focusRequest?.requestId;
|
||||||
|
const updateExpanded = useCallback(
|
||||||
|
(nextExpanded: boolean) => {
|
||||||
|
if (controlledExpanded === undefined) {
|
||||||
|
setUncontrolledExpanded(nextExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
onExpandedChange?.(nextExpanded);
|
||||||
|
},
|
||||||
|
[controlledExpanded, onExpandedChange]
|
||||||
|
);
|
||||||
|
const updateSelectedConditionId = useCallback(
|
||||||
|
(nextConditionId: string | null) => {
|
||||||
|
if (controlledSelectedConditionId === undefined) {
|
||||||
|
setUncontrolledSelectedConditionId(nextConditionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectedConditionChange?.(nextConditionId);
|
||||||
|
},
|
||||||
|
[controlledSelectedConditionId, onSelectedConditionChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskFilter === CONDITION_FILTER_ALL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
|
||||||
|
setTaskFilter(CONDITION_FILTER_ALL);
|
||||||
|
}
|
||||||
|
}, [taskFilter, taskFilterOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelectedConditionId = selectedCondition?.id ?? null;
|
||||||
|
if (selectedConditionId !== nextSelectedConditionId) {
|
||||||
|
updateSelectedConditionId(nextSelectedConditionId);
|
||||||
|
}
|
||||||
|
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusConditionId || focusRequestId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||||
|
if (!targetCondition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTaskFilter(CONDITION_FILTER_ALL);
|
||||||
|
setStatusFilter(CONDITION_FILTER_ALL);
|
||||||
|
updateSelectedConditionId(targetCondition.id);
|
||||||
|
updateExpanded(true);
|
||||||
|
onFocusRequestHandled?.(focusRequestId);
|
||||||
|
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||||
|
|
||||||
|
function handleToggleExpanded() {
|
||||||
|
updateExpanded(!expanded);
|
||||||
|
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectTimelineCondition(conditionId: string) {
|
||||||
|
updateSelectedConditionId(conditionId);
|
||||||
|
if (!expanded) {
|
||||||
|
updateExpanded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContinueConversation(condition: ScheduledConditionItem) {
|
||||||
|
if (condition.kind === "work_order") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onExpandAgent();
|
||||||
|
|
||||||
|
if (!condition.sessionId) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "scheduled-condition-missing-session",
|
||||||
|
tone: "error",
|
||||||
|
title: "无法打开历史会话",
|
||||||
|
message: "这条工况没有可加载的会话记录。"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
|
||||||
|
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
|
||||||
|
.then(() => {
|
||||||
|
showMapNotice({
|
||||||
|
id: "scheduled-condition-generated-session",
|
||||||
|
tone: "success",
|
||||||
|
title: "已发起工况对话",
|
||||||
|
message: "已将当前工况上下文发送到 Agent 面板。"
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((submitError) => {
|
||||||
|
showMapNotice({
|
||||||
|
id: "scheduled-condition-generated-session",
|
||||||
|
tone: "error",
|
||||||
|
title: "工况对话发起失败",
|
||||||
|
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Promise.resolve(onLoadHistorySession(condition.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
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",
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={handleToggleExpanded}
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
|
||||||
|
"rounded-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={17}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||||
|
expanded ? "scheduled-feed-layout-expanded" : "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"
|
||||||
|
)}
|
||||||
|
aria-hidden={!expanded}
|
||||||
|
>
|
||||||
|
{loading && expanded ? (
|
||||||
|
<ConditionDetailSkeleton />
|
||||||
|
) : expanded && selectedCondition ? (
|
||||||
|
<ConditionDetailPanel
|
||||||
|
condition={selectedCondition}
|
||||||
|
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||||
|
<ConditionTimelinePanel
|
||||||
|
expanded={expanded}
|
||||||
|
loading={loading}
|
||||||
|
recentConditions={recentConditions}
|
||||||
|
futureWorkOrders={futureWorkOrders}
|
||||||
|
taskFilter={taskFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
taskFilterOptions={taskFilterOptions}
|
||||||
|
statusFilterOptions={statusFilterOptions}
|
||||||
|
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||||
|
totalConditionCount={totalConditionCount}
|
||||||
|
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||||
|
filteredConditionCount={filteredConditionCount}
|
||||||
|
onTaskFilterChange={setTaskFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
onResetFilters={() => {
|
||||||
|
setTaskFilter(CONDITION_FILTER_ALL);
|
||||||
|
setStatusFilter(CONDITION_FILTER_ALL);
|
||||||
|
}}
|
||||||
|
onSelectCondition={handleSelectTimelineCondition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionTimelinePanelProps = {
|
||||||
|
expanded: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
recentConditions: ScheduledConditionItem[];
|
||||||
|
futureWorkOrders: ScheduledConditionItem[];
|
||||||
|
taskFilter: ConditionTaskFilterValue;
|
||||||
|
statusFilter: ConditionStatusFilterValue;
|
||||||
|
taskFilterOptions: ConditionTaskFilterOption[];
|
||||||
|
statusFilterOptions: ConditionStatusFilterOption[];
|
||||||
|
selectedConditionId: string | null;
|
||||||
|
totalConditionCount: number;
|
||||||
|
taskFilteredConditionCount: number;
|
||||||
|
filteredConditionCount: number;
|
||||||
|
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||||
|
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
onSelectCondition: (conditionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConditionTimelinePanel({
|
||||||
|
expanded,
|
||||||
|
loading,
|
||||||
|
recentConditions,
|
||||||
|
futureWorkOrders,
|
||||||
|
taskFilter,
|
||||||
|
statusFilter,
|
||||||
|
taskFilterOptions,
|
||||||
|
statusFilterOptions,
|
||||||
|
selectedConditionId,
|
||||||
|
totalConditionCount,
|
||||||
|
taskFilteredConditionCount,
|
||||||
|
filteredConditionCount,
|
||||||
|
onTaskFilterChange,
|
||||||
|
onStatusFilterChange,
|
||||||
|
onResetFilters,
|
||||||
|
onSelectCondition
|
||||||
|
}: ConditionTimelinePanelProps) {
|
||||||
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-busy={loading}
|
||||||
|
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]",
|
||||||
|
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{expanded && !loading ? (
|
||||||
|
<ConditionFilterBar
|
||||||
|
taskFilter={taskFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
taskFilterOptions={taskFilterOptions}
|
||||||
|
statusFilterOptions={statusFilterOptions}
|
||||||
|
filteredConditionCount={filteredConditionCount}
|
||||||
|
totalConditionCount={totalConditionCount}
|
||||||
|
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||||
|
onTaskFilterChange={onTaskFilterChange}
|
||||||
|
onStatusFilterChange={onStatusFilterChange}
|
||||||
|
onResetFilters={onResetFilters}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{expanded && !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">
|
||||||
|
<ConditionRows
|
||||||
|
conditions={futureWorkOrders}
|
||||||
|
selectedConditionId={selectedConditionId}
|
||||||
|
onSelectCondition={onSelectCondition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<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} />
|
||||||
|
) : recentConditions.length > 0 ? (
|
||||||
|
<ConditionRows
|
||||||
|
conditions={recentConditions}
|
||||||
|
selectedConditionId={selectedConditionId}
|
||||||
|
onSelectCondition={onSelectCondition}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConditionEmptyState
|
||||||
|
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||||
|
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionFilterBarProps = {
|
||||||
|
taskFilter: ConditionTaskFilterValue;
|
||||||
|
statusFilter: ConditionStatusFilterValue;
|
||||||
|
taskFilterOptions: ConditionTaskFilterOption[];
|
||||||
|
statusFilterOptions: ConditionStatusFilterOption[];
|
||||||
|
filteredConditionCount: number;
|
||||||
|
totalConditionCount: number;
|
||||||
|
taskFilteredConditionCount: number;
|
||||||
|
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||||
|
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConditionFilterBar({
|
||||||
|
taskFilter,
|
||||||
|
statusFilter,
|
||||||
|
taskFilterOptions,
|
||||||
|
statusFilterOptions,
|
||||||
|
filteredConditionCount,
|
||||||
|
totalConditionCount,
|
||||||
|
taskFilteredConditionCount,
|
||||||
|
onTaskFilterChange,
|
||||||
|
onStatusFilterChange,
|
||||||
|
onResetFilters
|
||||||
|
}: ConditionFilterBarProps) {
|
||||||
|
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="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">
|
||||||
|
{filteredConditionCount}/{totalConditionCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<ConditionFilterDropdown
|
||||||
|
label="任务"
|
||||||
|
value={taskFilter}
|
||||||
|
onValueChange={onTaskFilterChange}
|
||||||
|
options={[
|
||||||
|
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
|
||||||
|
...taskFilterOptions
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ConditionFilterDropdown
|
||||||
|
label="状态"
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
|
||||||
|
options={[
|
||||||
|
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
|
||||||
|
...statusFilterOptions
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filterActive ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<XCircle size={12} aria-hidden="true" />
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionFilterDropdownProps<TValue extends string> = {
|
||||||
|
label: string;
|
||||||
|
value: TValue;
|
||||||
|
options: {
|
||||||
|
value: TValue;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
onValueChange: (value: TValue) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConditionFilterDropdown<TValue extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onValueChange
|
||||||
|
}: ConditionFilterDropdownProps<TValue>) {
|
||||||
|
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-none transition-colors hover:bg-blue-50 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||||
|
"rounded-xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<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>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
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)}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-800",
|
||||||
|
"rounded-xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
|
||||||
|
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||||
|
{option.count}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-4 py-5 text-center">
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
|
||||||
|
<ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
|
||||||
|
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
) : null}
|
||||||
|
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||||
|
</span>
|
||||||
|
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2 py-1.5">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
|
||||||
|
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
|
||||||
|
</span>
|
||||||
|
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label="工况详情加载中"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
|
||||||
|
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
|
||||||
|
</div>
|
||||||
|
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
|
||||||
|
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||||
|
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailScroll>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionGroupHeaderProps = {
|
||||||
|
icon: typeof History;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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")}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Icon size={13} aria-hidden="true" />
|
||||||
|
<span>{title}</span>
|
||||||
|
</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionRowsProps = {
|
||||||
|
conditions: ScheduledConditionItem[];
|
||||||
|
selectedConditionId: string | null;
|
||||||
|
onSelectCondition: (conditionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{conditions.map((condition, index) => (
|
||||||
|
<ConditionTimelineRow
|
||||||
|
key={condition.id}
|
||||||
|
condition={condition}
|
||||||
|
selected={selectedConditionId === condition.id}
|
||||||
|
isLast={index === conditions.length - 1}
|
||||||
|
onSelect={() => onSelectCondition(condition.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionTimelineRowProps = {
|
||||||
|
condition: ScheduledConditionItem;
|
||||||
|
selected: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||||
|
const StatusIcon = getStatusIcon(condition.status);
|
||||||
|
const isWorkOrder = condition.kind === "work_order";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
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-none 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
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)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 px-2 py-1.5 transition-colors",
|
||||||
|
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>
|
||||||
|
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Camera,
|
||||||
|
Circle,
|
||||||
|
Copy,
|
||||||
|
Database,
|
||||||
|
Dot,
|
||||||
|
Download,
|
||||||
|
Keyboard,
|
||||||
|
Layers3,
|
||||||
|
MapPinned,
|
||||||
|
Pentagon,
|
||||||
|
Redo2,
|
||||||
|
RefreshCw,
|
||||||
|
Route,
|
||||||
|
Ruler,
|
||||||
|
Share2,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
Waypoints,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
MapAnnotationPanel,
|
||||||
|
type MapAnnotationItem,
|
||||||
|
type MapAnnotationShareAction
|
||||||
|
} from "@/features/map/core/components/annotation-panel";
|
||||||
|
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
||||||
|
import {
|
||||||
|
MapActionRow,
|
||||||
|
MapControlPanel,
|
||||||
|
MapMetricTile,
|
||||||
|
MapPanelSection
|
||||||
|
} from "@/features/map/core/components/control-panel";
|
||||||
|
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
|
||||||
|
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
|
||||||
|
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
||||||
|
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
|
||||||
|
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
|
||||||
|
import {
|
||||||
|
type WorkbenchDrawingAnnotation,
|
||||||
|
type WorkbenchDrawMode
|
||||||
|
} from "../hooks/use-workbench-drawing";
|
||||||
|
import {
|
||||||
|
type WorkbenchMeasurementResult,
|
||||||
|
type WorkbenchMeasureMode,
|
||||||
|
type WorkbenchMeasureUnit
|
||||||
|
} from "../hooks/use-workbench-measurement";
|
||||||
|
import { waterNetworkToolbarItems } from "../map/toolbar-config";
|
||||||
|
|
||||||
|
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
|
||||||
|
export type ExportViewPreset = "current" | "4k";
|
||||||
|
|
||||||
|
type MeasureModeOption = {
|
||||||
|
id: WorkbenchMeasureMode;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEASURE_MODES: MeasureModeOption[] = [
|
||||||
|
{ id: "distance", label: "距离", icon: Route },
|
||||||
|
{ id: "area", label: "面积", icon: Pentagon },
|
||||||
|
{ id: "segment", label: "管段", icon: Waypoints }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
|
||||||
|
{ id: "m", label: "m" },
|
||||||
|
{ id: "km", label: "km" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
|
||||||
|
{
|
||||||
|
id: "export",
|
||||||
|
icon: Share2,
|
||||||
|
label: "导出标注",
|
||||||
|
description: "GeoJSON / 截图报告"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_PANEL_EXIT_MS = 160;
|
||||||
|
|
||||||
|
const DRAW_TOOL_OPTIONS: Array<{
|
||||||
|
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
tone?: "danger";
|
||||||
|
}> = [
|
||||||
|
{ id: "point", label: "点标注", icon: Dot },
|
||||||
|
{ id: "line", label: "线标注", icon: Route },
|
||||||
|
{ id: "polygon", label: "范围标注", icon: Pentagon },
|
||||||
|
{ id: "circle", label: "圆形标注", icon: Circle },
|
||||||
|
{ id: "undo", label: "撤销", icon: Undo2 },
|
||||||
|
{ id: "redo", label: "重做", icon: Redo2 },
|
||||||
|
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
|
||||||
|
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ToolbarPanelProps = {
|
||||||
|
activeToolId: ToolbarToolId | null;
|
||||||
|
activeMeasureModeId: WorkbenchMeasureMode;
|
||||||
|
activeMeasureUnitId: WorkbenchMeasureUnit;
|
||||||
|
measuring: boolean;
|
||||||
|
measureResult: WorkbenchMeasurementResult;
|
||||||
|
measureEnabled: boolean;
|
||||||
|
hasMeasurePoints: boolean;
|
||||||
|
activeDrawToolId: WorkbenchDrawMode | null;
|
||||||
|
drawingEnabled: boolean;
|
||||||
|
drawingAnnotations: WorkbenchDrawingAnnotation[];
|
||||||
|
canUndoDrawing: boolean;
|
||||||
|
canRedoDrawing: boolean;
|
||||||
|
canDeleteSelectedDrawing: boolean;
|
||||||
|
hasDrawingFeatures: boolean;
|
||||||
|
layerItems: MapLayerControlItem[];
|
||||||
|
legendItems: MapLegendItem[];
|
||||||
|
baseLayerOptions: BaseLayerOption[];
|
||||||
|
activeBaseLayerId: string;
|
||||||
|
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
|
||||||
|
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
|
||||||
|
onStartMeasure: () => void;
|
||||||
|
onStopMeasure: () => void;
|
||||||
|
onClearMeasure: () => void;
|
||||||
|
onCopyMeasure: () => void;
|
||||||
|
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
|
||||||
|
onUndoDrawing: () => void;
|
||||||
|
onRedoDrawing: () => void;
|
||||||
|
onDeleteSelectedDrawing: () => void;
|
||||||
|
onClearDrawing: () => void;
|
||||||
|
onExportAnnotations: () => void;
|
||||||
|
onSelectBaseLayer: (id: string) => void;
|
||||||
|
onToggleLayer: (id: string, visible: boolean) => void;
|
||||||
|
onExportView: (preset: ExportViewPreset) => void;
|
||||||
|
onRefreshTiles: () => void;
|
||||||
|
onShowDataStatus: () => void;
|
||||||
|
onShowShortcuts: () => void;
|
||||||
|
onExportConfig: () => void;
|
||||||
|
panelWidthClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolbarPanel({
|
||||||
|
activeToolId,
|
||||||
|
activeMeasureModeId,
|
||||||
|
activeMeasureUnitId,
|
||||||
|
measuring,
|
||||||
|
measureResult,
|
||||||
|
measureEnabled,
|
||||||
|
hasMeasurePoints,
|
||||||
|
activeDrawToolId,
|
||||||
|
drawingEnabled,
|
||||||
|
drawingAnnotations,
|
||||||
|
canUndoDrawing,
|
||||||
|
canRedoDrawing,
|
||||||
|
canDeleteSelectedDrawing,
|
||||||
|
hasDrawingFeatures,
|
||||||
|
layerItems,
|
||||||
|
legendItems,
|
||||||
|
baseLayerOptions,
|
||||||
|
activeBaseLayerId,
|
||||||
|
onSelectMeasureMode,
|
||||||
|
onSelectMeasureUnit,
|
||||||
|
onStartMeasure,
|
||||||
|
onStopMeasure,
|
||||||
|
onClearMeasure,
|
||||||
|
onCopyMeasure,
|
||||||
|
onSelectDrawTool,
|
||||||
|
onUndoDrawing,
|
||||||
|
onRedoDrawing,
|
||||||
|
onDeleteSelectedDrawing,
|
||||||
|
onClearDrawing,
|
||||||
|
onExportAnnotations,
|
||||||
|
onSelectBaseLayer,
|
||||||
|
onToggleLayer,
|
||||||
|
onExportView,
|
||||||
|
onRefreshTiles,
|
||||||
|
onShowDataStatus,
|
||||||
|
onShowShortcuts,
|
||||||
|
onExportConfig,
|
||||||
|
panelWidthClassName
|
||||||
|
}: ToolbarPanelProps) {
|
||||||
|
const annotationItems = useMemo<MapAnnotationItem[]>(
|
||||||
|
() =>
|
||||||
|
drawingAnnotations.map((annotation) => ({
|
||||||
|
id: annotation.id,
|
||||||
|
icon: getDrawingAnnotationIcon(annotation.kind),
|
||||||
|
label: annotation.label,
|
||||||
|
description: annotation.description,
|
||||||
|
status: annotation.hidden ? "隐藏" : "显示",
|
||||||
|
selected: annotation.selected,
|
||||||
|
muted: annotation.hidden,
|
||||||
|
onClick: annotation.onToggleVisibility
|
||||||
|
})),
|
||||||
|
[drawingAnnotations]
|
||||||
|
);
|
||||||
|
const annotationShareActions = useMemo(
|
||||||
|
() =>
|
||||||
|
ANNOTATION_SHARE_ACTIONS.map((action) => ({
|
||||||
|
...action,
|
||||||
|
onClick: action.id === "export" ? onExportAnnotations : action.onClick
|
||||||
|
})),
|
||||||
|
[onExportAnnotations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
|
||||||
|
const panelVisible = Boolean(activeToolId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeToolId) {
|
||||||
|
setRenderedToolId(activeToolId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderedToolId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitTimer = window.setTimeout(() => {
|
||||||
|
setRenderedToolId(null);
|
||||||
|
}, TOOL_PANEL_EXIT_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(exitTimer);
|
||||||
|
}, [activeToolId, renderedToolId]);
|
||||||
|
|
||||||
|
if (!renderedToolId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
|
||||||
|
const Icon = activeTool?.icon ?? Layers3;
|
||||||
|
|
||||||
|
if (renderedToolId === "layers") {
|
||||||
|
return (
|
||||||
|
<MapControlPanel
|
||||||
|
key="layers"
|
||||||
|
title="图层"
|
||||||
|
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
|
||||||
|
icon={Icon}
|
||||||
|
widthClassName={panelWidthClassName}
|
||||||
|
visible={panelVisible}
|
||||||
|
>
|
||||||
|
<MapLayerPanel
|
||||||
|
layerItems={layerItems}
|
||||||
|
baseLayerOptions={baseLayerOptions}
|
||||||
|
activeBaseLayerId={activeBaseLayerId}
|
||||||
|
onSelectBaseLayer={onSelectBaseLayer}
|
||||||
|
onToggleLayer={onToggleLayer}
|
||||||
|
/>
|
||||||
|
</MapControlPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedToolId === "measure") {
|
||||||
|
return (
|
||||||
|
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||||
|
<MapMeasurePanel
|
||||||
|
modes={MEASURE_MODES}
|
||||||
|
activeModeId={activeMeasureModeId}
|
||||||
|
resultLabel={measureResult.label}
|
||||||
|
resultValue={measureResult.value}
|
||||||
|
resultUnit={measureResult.unit}
|
||||||
|
metrics={measureResult.metrics}
|
||||||
|
units={MEASURE_UNITS}
|
||||||
|
activeUnitId={activeMeasureUnitId}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: measuring ? "stop" : "start",
|
||||||
|
icon: Ruler,
|
||||||
|
label: measuring ? "停止测量" : "开始测量",
|
||||||
|
description:
|
||||||
|
activeMeasureModeId === "segment"
|
||||||
|
? measuring
|
||||||
|
? "暂停管线选择"
|
||||||
|
: "点击管线选择资产"
|
||||||
|
: measuring
|
||||||
|
? "暂停地图取点"
|
||||||
|
: "在地图上连续点击取点",
|
||||||
|
variant: measuring ? "default" : "primary",
|
||||||
|
disabled: !measureEnabled,
|
||||||
|
onClick: measuring ? onStopMeasure : onStartMeasure
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy",
|
||||||
|
icon: Copy,
|
||||||
|
label: "复制结果",
|
||||||
|
description: "复制当前读数",
|
||||||
|
disabled: !hasMeasurePoints,
|
||||||
|
onClick: onCopyMeasure
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clear",
|
||||||
|
icon: Trash2,
|
||||||
|
label: "清除测量",
|
||||||
|
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
|
||||||
|
tone: "danger",
|
||||||
|
disabled: !hasMeasurePoints,
|
||||||
|
onClick: onClearMeasure
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
|
||||||
|
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
|
||||||
|
/>
|
||||||
|
</MapControlPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedToolId === "analysis") {
|
||||||
|
return (
|
||||||
|
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
|
||||||
|
<AnalysisPanel legendItems={legendItems} />
|
||||||
|
</MapControlPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedToolId === "annotation") {
|
||||||
|
return (
|
||||||
|
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||||
|
<MapPanelSection title="绘制">
|
||||||
|
<MapDrawToolbar
|
||||||
|
variant="panel"
|
||||||
|
items={DRAW_TOOL_OPTIONS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
active: activeDrawToolId === item.id,
|
||||||
|
disabled:
|
||||||
|
!drawingEnabled ||
|
||||||
|
(item.id === "undo" && !canUndoDrawing) ||
|
||||||
|
(item.id === "redo" && !canRedoDrawing) ||
|
||||||
|
(item.id === "delete" && !canDeleteSelectedDrawing) ||
|
||||||
|
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
|
||||||
|
onClick: () => {
|
||||||
|
if (item.id === "undo") {
|
||||||
|
onUndoDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "redo") {
|
||||||
|
onRedoDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "delete") {
|
||||||
|
onDeleteSelectedDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "clear") {
|
||||||
|
onClearDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
|
||||||
|
}
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</MapPanelSection>
|
||||||
|
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
|
||||||
|
</MapControlPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
|
||||||
|
<MorePanel
|
||||||
|
onExportView={onExportView}
|
||||||
|
onRefreshTiles={onRefreshTiles}
|
||||||
|
onShowDataStatus={onShowDataStatus}
|
||||||
|
onShowShortcuts={onShowShortcuts}
|
||||||
|
onExportConfig={onExportConfig}
|
||||||
|
/>
|
||||||
|
</MapControlPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MapPanelSection title="图例">
|
||||||
|
<MapLegendList items={legendItems} showStatusDot />
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
<MapPanelSection title="当前覆盖">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<MapMetricTile label="影响范围" value="未开启" />
|
||||||
|
<MapMetricTile label="风险预览" value="待生成" />
|
||||||
|
</div>
|
||||||
|
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
|
||||||
|
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
|
||||||
|
</MapPanelSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MorePanelProps = {
|
||||||
|
onExportView: (preset: ExportViewPreset) => void;
|
||||||
|
onRefreshTiles: () => void;
|
||||||
|
onShowDataStatus: () => void;
|
||||||
|
onShowShortcuts: () => void;
|
||||||
|
onExportConfig: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MorePanel({
|
||||||
|
onExportView,
|
||||||
|
onRefreshTiles,
|
||||||
|
onShowDataStatus,
|
||||||
|
onShowShortcuts,
|
||||||
|
onExportConfig
|
||||||
|
}: MorePanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MapPanelSection title="导出视图">
|
||||||
|
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
|
||||||
|
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
<MapPanelSection title="数据">
|
||||||
|
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
|
||||||
|
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
|
||||||
|
</MapPanelSection>
|
||||||
|
|
||||||
|
<MapPanelSection title="系统">
|
||||||
|
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
|
||||||
|
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
|
||||||
|
</MapPanelSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
|
||||||
|
if (kind === "line") {
|
||||||
|
return Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "polygon") {
|
||||||
|
return Pentagon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "circle") {
|
||||||
|
return Circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapPinned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Bell,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Keyboard,
|
||||||
|
LogOut,
|
||||||
|
PlayCircle,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
UserRound,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME
|
||||||
|
} from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/shared/ui/dropdown-menu";
|
||||||
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
|
import {
|
||||||
|
compactHeaderTextClassName,
|
||||||
|
headerControlButtonClassName,
|
||||||
|
headerControlIconClassName
|
||||||
|
} from "./workbench-top-bar-styles";
|
||||||
|
|
||||||
|
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||||
|
active: "运行中",
|
||||||
|
draft: "草案",
|
||||||
|
review: "待复核"
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuSurfaceClassName = cn(
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||||
|
"acrylic-panel border p-2 text-slate-900"
|
||||||
|
);
|
||||||
|
const menuReadableRowClassName =
|
||||||
|
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
||||||
|
export function ScenarioMenu({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
scenarios,
|
||||||
|
activeScenarioId,
|
||||||
|
activeScenarioName,
|
||||||
|
onSelectScenario,
|
||||||
|
onPreviewScenario,
|
||||||
|
onCompareScenario,
|
||||||
|
onExportScenarioReport
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
scenarios: WorkbenchScenario[];
|
||||||
|
activeScenarioId: string;
|
||||||
|
activeScenarioName: string;
|
||||||
|
onSelectScenario: (scenarioId: string) => void;
|
||||||
|
onPreviewScenario: () => void;
|
||||||
|
onCompareScenario: () => void;
|
||||||
|
onExportScenarioReport: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
|
||||||
|
aria-label="切换模拟方案"
|
||||||
|
>
|
||||||
|
<span className={headerControlIconClassName}>
|
||||||
|
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
|
||||||
|
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
||||||
|
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
|
||||||
|
<MenuSectionLabel>场景列表</MenuSectionLabel>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={activeScenarioId}
|
||||||
|
onValueChange={(nextScenarioId) => {
|
||||||
|
onSelectScenario(nextScenarioId);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scenarios.map((scenario) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={scenario.id}
|
||||||
|
value={scenario.id}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
menuReadableRowClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{scenarioStatusLabels[scenario.status]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuSectionLabel>场景命令</MenuSectionLabel>
|
||||||
|
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
|
||||||
|
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
|
||||||
|
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertMenu({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
alerts,
|
||||||
|
compact = false,
|
||||||
|
conditionFeedVisible,
|
||||||
|
onSelectAlert,
|
||||||
|
onToggleConditionFeed,
|
||||||
|
onAnalyzeAlertsWithAgent
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
alerts: WorkbenchAlert[];
|
||||||
|
compact?: boolean;
|
||||||
|
conditionFeedVisible: boolean;
|
||||||
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||||
|
onToggleConditionFeed: () => void;
|
||||||
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const hasAlerts = alerts.length > 0;
|
||||||
|
const latestAlertTime = alerts[0]?.time;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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")}>
|
||||||
|
{alerts.length}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<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")}>
|
||||||
|
{alerts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
|
||||||
|
<AlertQueueSummary
|
||||||
|
count={alerts.length}
|
||||||
|
latestAlertTime={latestAlertTime}
|
||||||
|
conditionFeedVisible={conditionFeedVisible}
|
||||||
|
/>
|
||||||
|
<MenuSectionLabel>待复核工况</MenuSectionLabel>
|
||||||
|
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
||||||
|
{hasAlerts ? alerts.map((alert) => (
|
||||||
|
<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",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
menuReadableRowClassName
|
||||||
|
)}
|
||||||
|
onSelect={() => onSelectAlert(alert)}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2">
|
||||||
|
<MenuPlainButton
|
||||||
|
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
|
||||||
|
description="查看时间线与处置详情"
|
||||||
|
disabled={conditionFeedVisible}
|
||||||
|
onClick={() => {
|
||||||
|
onToggleConditionFeed();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuPrimaryButton
|
||||||
|
disabled={!hasAlerts}
|
||||||
|
icon={Bot}
|
||||||
|
label={hasAlerts ? "工况汇总" : "等待提醒"}
|
||||||
|
onClick={() => {
|
||||||
|
void onAnalyzeAlertsWithAgent();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
user,
|
||||||
|
onRefreshTiles,
|
||||||
|
onShowDataStatus,
|
||||||
|
onShowShortcuts,
|
||||||
|
onExportConfig
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
user: WorkbenchUser;
|
||||||
|
onRefreshTiles: () => void;
|
||||||
|
onShowDataStatus: () => void;
|
||||||
|
onShowShortcuts: () => void;
|
||||||
|
onExportConfig: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("group px-2", headerControlButtonClassName)}
|
||||||
|
aria-label="打开用户菜单"
|
||||||
|
>
|
||||||
|
<span className={headerControlIconClassName}>
|
||||||
|
<UserRound size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
|
||||||
|
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||||
|
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
||||||
|
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||||
|
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||||
|
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||||
|
<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)}>
|
||||||
|
<LogOut size={15} aria-hidden="true" />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertQueueSummary({
|
||||||
|
count,
|
||||||
|
latestAlertTime,
|
||||||
|
conditionFeedVisible
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
latestAlertTime?: string;
|
||||||
|
conditionFeedVisible: boolean;
|
||||||
|
}) {
|
||||||
|
const hasAlerts = count > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
|
||||||
|
</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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasAlerts ? "需复核" : "正常"}
|
||||||
|
</span>
|
||||||
|
</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" />
|
||||||
|
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuPlainButton({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
disabled = false,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
disabled
|
||||||
|
? "border-slate-200 bg-slate-50 text-slate-500"
|
||||||
|
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-xs font-semibold leading-4">{label}</span>
|
||||||
|
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuPrimaryButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
disabled
|
||||||
|
? "border-slate-200 bg-slate-50 text-slate-400"
|
||||||
|
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} aria-hidden="true" />
|
||||||
|
<span className="max-w-[92px] truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuHeader({
|
||||||
|
title,
|
||||||
|
meta,
|
||||||
|
tone = "default",
|
||||||
|
icon: Icon
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
tone?: "default" | "warning";
|
||||||
|
icon?: LucideIcon;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSectionLabel({ children }: { children: ReactNode }) {
|
||||||
|
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSeparator() {
|
||||||
|
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuAction({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
tone = "default",
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone?: "default" | "primary" | "warning";
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 items-center border border-transparent px-2 py-2 transition",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
menuReadableRowClassName
|
||||||
|
)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"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 === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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-none 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";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const compactHeaderTextClassName = "hidden 2xl:inline";
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
CalendarClock,
|
||||||
|
Clock3,
|
||||||
|
Droplets,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FlaskConical,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
|
} from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
|
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||||
|
import {
|
||||||
|
compactHeaderTextClassName,
|
||||||
|
headerControlButtonClassName,
|
||||||
|
headerControlIconClassName
|
||||||
|
} from "./workbench-top-bar-styles";
|
||||||
|
|
||||||
|
export type WorkbenchTopBarProps = {
|
||||||
|
dataTime: string;
|
||||||
|
modelName: string;
|
||||||
|
scenarios: WorkbenchScenario[];
|
||||||
|
activeScenarioId: string;
|
||||||
|
alerts: WorkbenchAlert[];
|
||||||
|
user: WorkbenchUser;
|
||||||
|
conditionFeedVisible: boolean;
|
||||||
|
taskTickerAvailable: boolean;
|
||||||
|
taskTickerVisible: boolean;
|
||||||
|
devPanelEnabled: boolean;
|
||||||
|
devPanelOpen: boolean;
|
||||||
|
onSelectScenario: (scenarioId: string) => void;
|
||||||
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||||
|
onToggleConditionFeed: () => void;
|
||||||
|
onToggleTaskTicker: () => void;
|
||||||
|
onToggleDevPanel: () => void;
|
||||||
|
onPreviewScenario: () => void;
|
||||||
|
onCompareScenario: () => void;
|
||||||
|
onExportScenarioReport: () => void;
|
||||||
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||||
|
onShowDataStatus: () => void;
|
||||||
|
onRefreshTiles: () => void;
|
||||||
|
onShowShortcuts: () => void;
|
||||||
|
onExportConfig: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||||
|
|
||||||
|
export function WorkbenchTopBar({
|
||||||
|
dataTime,
|
||||||
|
modelName,
|
||||||
|
scenarios,
|
||||||
|
activeScenarioId,
|
||||||
|
alerts,
|
||||||
|
user,
|
||||||
|
conditionFeedVisible,
|
||||||
|
taskTickerAvailable,
|
||||||
|
taskTickerVisible,
|
||||||
|
devPanelEnabled,
|
||||||
|
devPanelOpen,
|
||||||
|
onSelectScenario,
|
||||||
|
onSelectAlert,
|
||||||
|
onToggleConditionFeed,
|
||||||
|
onToggleTaskTicker,
|
||||||
|
onToggleDevPanel,
|
||||||
|
onPreviewScenario,
|
||||||
|
onCompareScenario,
|
||||||
|
onExportScenarioReport,
|
||||||
|
onAnalyzeAlertsWithAgent,
|
||||||
|
onShowDataStatus,
|
||||||
|
onRefreshTiles,
|
||||||
|
onShowShortcuts,
|
||||||
|
onExportConfig
|
||||||
|
}: WorkbenchTopBarProps) {
|
||||||
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||||
|
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||||
|
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||||
|
setOpenMenu(open ? menuId : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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)}>
|
||||||
|
<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>
|
||||||
|
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden min-w-0 items-center gap-2 text-sm text-slate-700 lg:flex 2xl:gap-4">
|
||||||
|
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
||||||
|
<HeaderDivider />
|
||||||
|
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
||||||
|
<HeaderDivider />
|
||||||
|
<TaskTickerToggle
|
||||||
|
available={taskTickerAvailable}
|
||||||
|
visible={taskTickerVisible}
|
||||||
|
onToggle={onToggleTaskTicker}
|
||||||
|
/>
|
||||||
|
{taskTickerAvailable ? <HeaderDivider /> : null}
|
||||||
|
<ConditionFeedToggle visible={conditionFeedVisible} onToggle={onToggleConditionFeed} />
|
||||||
|
<HeaderDivider />
|
||||||
|
<AlertMenu
|
||||||
|
open={openMenu === "alerts"}
|
||||||
|
onOpenChange={createMenuOpenChange("alerts")}
|
||||||
|
alerts={alerts}
|
||||||
|
conditionFeedVisible={conditionFeedVisible}
|
||||||
|
onSelectAlert={onSelectAlert}
|
||||||
|
onToggleConditionFeed={onToggleConditionFeed}
|
||||||
|
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||||
|
/>
|
||||||
|
<HeaderDivider />
|
||||||
|
{activeScenario ? (
|
||||||
|
<ScenarioMenu
|
||||||
|
open={openMenu === "scenario"}
|
||||||
|
onOpenChange={createMenuOpenChange("scenario")}
|
||||||
|
scenarios={scenarios}
|
||||||
|
activeScenarioId={activeScenario.id}
|
||||||
|
activeScenarioName={activeScenario.name}
|
||||||
|
onSelectScenario={onSelectScenario}
|
||||||
|
onPreviewScenario={onPreviewScenario}
|
||||||
|
onCompareScenario={onCompareScenario}
|
||||||
|
onExportScenarioReport={onExportScenarioReport}
|
||||||
|
/>
|
||||||
|
) : 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="lg:hidden">
|
||||||
|
<AlertMenu
|
||||||
|
compact
|
||||||
|
open={openMenu === "compact-alerts"}
|
||||||
|
onOpenChange={createMenuOpenChange("compact-alerts")}
|
||||||
|
alerts={alerts}
|
||||||
|
conditionFeedVisible={conditionFeedVisible}
|
||||||
|
onSelectAlert={onSelectAlert}
|
||||||
|
onToggleConditionFeed={onToggleConditionFeed}
|
||||||
|
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UserMenu
|
||||||
|
open={openMenu === "user"}
|
||||||
|
onOpenChange={createMenuOpenChange("user")}
|
||||||
|
user={user}
|
||||||
|
onRefreshTiles={onRefreshTiles}
|
||||||
|
onShowDataStatus={onShowDataStatus}
|
||||||
|
onShowShortcuts={onShowShortcuts}
|
||||||
|
onExportConfig={onExportConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskTickerToggle({
|
||||||
|
available,
|
||||||
|
visible,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
available: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = visible ? EyeOff : Eye;
|
||||||
|
|
||||||
|
if (!available) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||||
|
aria-pressed={visible}
|
||||||
|
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"group px-2",
|
||||||
|
headerControlButtonClassName,
|
||||||
|
visible && "bg-blue-50/80 text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
headerControlIconClassName,
|
||||||
|
visible && "bg-blue-600/10 text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionFeedToggle({
|
||||||
|
visible,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={visible}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"group px-2",
|
||||||
|
headerControlButtonClassName,
|
||||||
|
visible && "bg-blue-50/80 text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
headerControlIconClassName,
|
||||||
|
visible && "bg-blue-600/10 text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarClock size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderDivider() {
|
||||||
|
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type {
|
||||||
|
ScheduledConditionRecord,
|
||||||
|
ScheduledConditionStatus,
|
||||||
|
ScheduledConditionTaskId
|
||||||
|
} from "@/features/workbench/types";
|
||||||
|
|
||||||
|
export type RunningWorkflowStepDefinition = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunningWorkflowDefinition = {
|
||||||
|
title: string;
|
||||||
|
reportLabel: string;
|
||||||
|
steps: RunningWorkflowStepDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunningWorkflowStepStatus = "done" | "current" | "pending";
|
||||||
|
|
||||||
|
export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, RunningWorkflowDefinition> = {
|
||||||
|
"scada-diagnosis": {
|
||||||
|
title: "SCADA 诊断流程",
|
||||||
|
reportLabel: "生成诊断报告",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "ingest-telemetry",
|
||||||
|
title: "读取 SCADA 遥测",
|
||||||
|
detail: "拉取压力、流量和关键阀门测点,校验时间戳与回传频率。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "freshness-check",
|
||||||
|
title: "检查新鲜度完整率",
|
||||||
|
detail: "按测点组计算缺测、延迟和连续异常样本比例。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "consistency-check",
|
||||||
|
title: "压流一致性诊断",
|
||||||
|
detail: "比对压力变化、流量变化和相邻测点趋势是否符合水力逻辑。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "isolate-sources",
|
||||||
|
title: "标记异常数据源",
|
||||||
|
detail: "圈定疑似通信异常或越界测点,给后续模拟和调度提供屏蔽建议。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diagnosis-report",
|
||||||
|
title: "整理诊断结论",
|
||||||
|
detail: "汇总数据质量、异常证据和是否允许进入后续工况任务。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"smart-dispatch": {
|
||||||
|
title: "智能调度流程",
|
||||||
|
reportLabel: "生成调度指令",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "read-condition",
|
||||||
|
title: "读取实时工况",
|
||||||
|
detail: "汇总目标分区压力、流量、泵站出水和阀门开度。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "candidate-actions",
|
||||||
|
title: "生成候选动作",
|
||||||
|
detail: "枚举泵站目标压力、阀门边界和高频复核测点的可执行动作。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "simulate-impact",
|
||||||
|
title: "模拟影响范围",
|
||||||
|
detail: "预估指令执行后的压力改善、分区压差和关键用户影响。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rank-actions",
|
||||||
|
title: "排序调度方案",
|
||||||
|
detail: "按改善幅度、操作代价和数据可信度筛选建议指令。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dispatch-draft",
|
||||||
|
title: "形成复核清单",
|
||||||
|
detail: "输出待调度员确认的管网要素调整指令和复核条件。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"network-simulation": {
|
||||||
|
title: "定时模拟流程",
|
||||||
|
reportLabel: "生成模拟报告",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "sync-boundary",
|
||||||
|
title: "同步模型边界",
|
||||||
|
detail: "读取最新泵站启停、阀门开度、需水量和 SCADA 边界条件。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "run-hydraulic-model",
|
||||||
|
title: "运行水力模型",
|
||||||
|
detail: "滚动计算全网节点压力、管段流量和分区边界平衡。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compare-telemetry",
|
||||||
|
title: "比对实测偏差",
|
||||||
|
detail: "将模拟压力、流量与在线测点回传值进行偏差校验。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "locate-deviation",
|
||||||
|
title: "定位偏差来源",
|
||||||
|
detail: "识别模型参数、边界条件或遥测源导致的主要偏差区域。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "simulation-report",
|
||||||
|
title: "输出模型复核",
|
||||||
|
detail: "给出可信度、关注指标和是否允许用于调度决策。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pump-energy": {
|
||||||
|
title: "泵站能耗流程",
|
||||||
|
reportLabel: "生成能耗报告",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "read-pump-load",
|
||||||
|
title: "读取泵组负载",
|
||||||
|
detail: "采集泵站频率、电流、出水压力、出水量和启停记录。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calculate-energy",
|
||||||
|
title: "计算单位电耗",
|
||||||
|
detail: "按当前窗口排水量折算单位排水电耗和效率偏差。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compare-efficiency",
|
||||||
|
title: "比对经济区间",
|
||||||
|
detail: "结合泵组效率曲线判断当前组合是否处于低效并联区间。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pressure-guard",
|
||||||
|
title: "校验供压约束",
|
||||||
|
detail: "复核节能建议不会引发末端低压或分区边界异常。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "energy-advice",
|
||||||
|
title: "形成节能建议",
|
||||||
|
detail: "输出启停优化、压力设定和继续观察条件。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
|
||||||
|
return runningWorkflowDefinitions[condition.taskId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunningWorkflowState(
|
||||||
|
condition: ScheduledConditionRecord,
|
||||||
|
workflow: RunningWorkflowDefinition,
|
||||||
|
nowMs: number
|
||||||
|
) {
|
||||||
|
if (condition.status !== "running") {
|
||||||
|
return { currentStepIndex: workflow.steps.length - 1, progress: getCompletedProgress(condition.status) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledAtMs = Date.parse(condition.scheduledAt);
|
||||||
|
const startedAtMs = Number.isFinite(scheduledAtMs) ? scheduledAtMs : nowMs;
|
||||||
|
const durationMs = Math.max(condition.durationMinutes ?? 1, 1) * 60_000;
|
||||||
|
const elapsedMs = Math.max(0, nowMs - startedAtMs);
|
||||||
|
const rawRatio = elapsedMs / durationMs;
|
||||||
|
const runningRatio = Math.min(Math.max(rawRatio, 0), 0.999);
|
||||||
|
const currentStepIndex = Math.min(
|
||||||
|
workflow.steps.length - 1,
|
||||||
|
Math.floor(runningRatio * workflow.steps.length)
|
||||||
|
);
|
||||||
|
const progress = Math.min(99, Math.max(0, Math.floor(runningRatio * 100)));
|
||||||
|
|
||||||
|
return { currentStepIndex, progress };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkflowStepStatus(
|
||||||
|
condition: ScheduledConditionRecord,
|
||||||
|
stepIndex: number,
|
||||||
|
currentStepIndex: number
|
||||||
|
): RunningWorkflowStepStatus {
|
||||||
|
if (condition.status !== "running") {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex < currentStepIndex) {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex === currentStepIndex) {
|
||||||
|
return "current";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletedProgress(status: ScheduledConditionStatus) {
|
||||||
|
if (status === "error") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createOperationalScheduledConditions } from "./scheduled-conditions";
|
||||||
|
|
||||||
|
describe("createOperationalScheduledConditions", () => {
|
||||||
|
it("keeps a scheduled condition running during the previous gap window", () => {
|
||||||
|
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:43:30+08:00"));
|
||||||
|
const runningCondition = conditions.find(
|
||||||
|
(condition) => condition.kind === "condition" && condition.status === "running"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runningCondition?.title).toBe("SCADA 数据诊断");
|
||||||
|
expect(runningCondition?.durationMinutes).toBe(5);
|
||||||
|
expect(runningCondition?.scheduledAt).toContain("T11:40:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls the running condition into history when the next interval starts", () => {
|
||||||
|
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:45:30+08:00"));
|
||||||
|
const previousCondition = conditions.find(
|
||||||
|
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071140")
|
||||||
|
);
|
||||||
|
const currentCondition = conditions.find(
|
||||||
|
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071145")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(previousCondition?.status).not.toBe("running");
|
||||||
|
expect(previousCondition?.durationMinutes).toBe(5);
|
||||||
|
expect(currentCondition?.status).toBe("running");
|
||||||
|
expect(currentCondition?.durationMinutes).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
import type { WorkbenchScenario, WorkbenchUser } from "../types";
|
||||||
|
|
||||||
|
export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
|
||||||
|
{
|
||||||
|
id: "scenario-a",
|
||||||
|
name: "方案 A",
|
||||||
|
description: "关闭事故点上下游阀门,优先保障核心片区压力。",
|
||||||
|
status: "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scenario-b",
|
||||||
|
name: "方案 B",
|
||||||
|
description: "扩大隔离范围,降低抢修期间二次爆管风险。",
|
||||||
|
status: "review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scenario-c",
|
||||||
|
name: "夜间保供",
|
||||||
|
description: "按夜间低峰工况调整泵站与分区调度策略。",
|
||||||
|
status: "draft"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WORKBENCH_USER: WorkbenchUser = {
|
||||||
|
name: "调度员",
|
||||||
|
role: "排水运行调度中心"
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { MapAnnotation } from "../types";
|
||||||
|
|
||||||
|
export const mapAnnotations: MapAnnotation[] = [
|
||||||
|
{
|
||||||
|
id: "burst",
|
||||||
|
label: "爆管位置",
|
||||||
|
coordinate: [121.5215, 30.9084],
|
||||||
|
kind: "burst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dn600",
|
||||||
|
label: "DN600",
|
||||||
|
coordinate: [121.512, 30.9102],
|
||||||
|
kind: "pipe-label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "east-gate",
|
||||||
|
label: "东直门小区",
|
||||||
|
coordinate: [121.545, 30.951],
|
||||||
|
kind: "district"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "impact",
|
||||||
|
label: "影响范围 约 1.82 km²",
|
||||||
|
coordinate: [121.567, 30.896],
|
||||||
|
kind: "tooltip"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const impactAreaPolygon = {
|
||||||
|
type: "FeatureCollection" as const,
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
properties: {
|
||||||
|
id: "impact-area",
|
||||||
|
label: "影响范围",
|
||||||
|
area: "1.82 km²"
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon" as const,
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[121.5205, 30.9084],
|
||||||
|
[121.548, 30.928],
|
||||||
|
[121.604, 30.918],
|
||||||
|
[121.626, 30.886],
|
||||||
|
[121.584, 30.862],
|
||||||
|
[121.538, 30.873],
|
||||||
|
[121.506, 30.895],
|
||||||
|
[121.5205, 30.9084]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const annotationPoints = {
|
||||||
|
type: "FeatureCollection" as const,
|
||||||
|
features: mapAnnotations.map((annotation) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
properties: {
|
||||||
|
id: annotation.id,
|
||||||
|
label: annotation.label,
|
||||||
|
kind: annotation.kind
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point" as const,
|
||||||
|
coordinates: annotation.coordinate
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type {
|
||||||
|
AgentChatMessage,
|
||||||
|
AgentPermissionReply,
|
||||||
|
AgentPermissionStatus,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentStreamRenderState
|
||||||
|
} from "@/features/agent";
|
||||||
|
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
|
||||||
|
import {
|
||||||
|
applyPermissionResponse,
|
||||||
|
applyQuestionResponse,
|
||||||
|
toTodoUpdate,
|
||||||
|
upsertPermission,
|
||||||
|
upsertProgress,
|
||||||
|
upsertQuestion
|
||||||
|
} from "@/features/agent/session-state";
|
||||||
|
|
||||||
|
type AgentUiDataParts = {
|
||||||
|
progress: Record<string, unknown>;
|
||||||
|
todo_update: Record<string, unknown>;
|
||||||
|
permission_request: Record<string, unknown>;
|
||||||
|
permission_response: Record<string, unknown>;
|
||||||
|
question_request: Record<string, unknown>;
|
||||||
|
question_response: Record<string, unknown>;
|
||||||
|
stream_token: Record<string, unknown>;
|
||||||
|
tool_call: Record<string, unknown>;
|
||||||
|
frontend_action: Record<string, unknown>;
|
||||||
|
frontend_action_result: Record<string, unknown>;
|
||||||
|
ui_envelope: Record<string, unknown>;
|
||||||
|
session_title: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
|
||||||
|
|
||||||
|
export type AgentDataPart = Extract<AgentUiMessage["parts"][number], { type: `data-${string}` }>;
|
||||||
|
|
||||||
|
export type PermissionOverride = {
|
||||||
|
status: AgentPermissionStatus;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionOverride = {
|
||||||
|
status: AgentQuestionRequest["status"];
|
||||||
|
answers?: string[][];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
|
||||||
|
|
||||||
|
export function markStreamRenderPending(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
messages: AgentUiMessage[],
|
||||||
|
setStreamRenderState: StreamRenderStateSetter
|
||||||
|
) {
|
||||||
|
const text = getDataString(data, "content");
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = getDataString(data, "message_id") ?? getLastAssistantUiMessageId(messages);
|
||||||
|
if (!messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStreamRenderState((current) => {
|
||||||
|
if (current[messageId]?.done === false) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[messageId]: { done: false }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
|
||||||
|
return messages.reduce<AgentStreamRenderState>((next, message) => {
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
next[message.id] = { done: true };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markLastAssistantStreamDone(
|
||||||
|
messages: AgentUiMessage[],
|
||||||
|
setStreamRenderState: StreamRenderStateSetter
|
||||||
|
) {
|
||||||
|
const messageId = getLastAssistantUiMessageId(messages);
|
||||||
|
setStreamRenderState((current) => {
|
||||||
|
if (!messageId) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(current).map(([id, state]) => [id, { ...state, done: true }])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[messageId]: { done: true }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAgentChatMessages(
|
||||||
|
messages: AgentUiMessage[],
|
||||||
|
permissionOverrides: Record<string, PermissionOverride>,
|
||||||
|
questionOverrides: Record<string, QuestionOverride>
|
||||||
|
): AgentChatMessage[] {
|
||||||
|
return messages.flatMap((message) => {
|
||||||
|
if (message.role !== "user" && message.role !== "assistant") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let next: AgentChatMessage = {
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
content: collectMessageText(message)
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const part of message.parts) {
|
||||||
|
if (!isAgentDataPart(part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.type === "data-progress") {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
progress: upsertProgress(next.progress, part.data)
|
||||||
|
};
|
||||||
|
} else if (part.type === "data-todo_update") {
|
||||||
|
const todoUpdate = toTodoUpdate(part.data);
|
||||||
|
if (todoUpdate) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
todos: todoUpdate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (part.type === "data-permission_request") {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
permissions: upsertPermission(next.permissions, part.data)
|
||||||
|
};
|
||||||
|
} else if (part.type === "data-permission_response") {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
permissions: applyPermissionResponse(next.permissions, part.data)
|
||||||
|
};
|
||||||
|
} else if (part.type === "data-question_request") {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
questions: upsertQuestion(next.questions, part.data)
|
||||||
|
};
|
||||||
|
} else if (part.type === "data-question_response") {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
questions: applyQuestionResponse(next.questions, part.data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
|
||||||
|
return messages.flatMap((message) => {
|
||||||
|
if (isAgentUiMessage(message)) {
|
||||||
|
return [message];
|
||||||
|
}
|
||||||
|
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
||||||
|
return legacyMessage ? [legacyMessage] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
|
||||||
|
if (event.type === "state") {
|
||||||
|
return toAgentUiMessages(event.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "token") {
|
||||||
|
const token = getDataString(event.data, "content");
|
||||||
|
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "progress" ||
|
||||||
|
event.type === "todo_update" ||
|
||||||
|
event.type === "permission_request" ||
|
||||||
|
event.type === "permission_response" ||
|
||||||
|
event.type === "question_request" ||
|
||||||
|
event.type === "question_response" ||
|
||||||
|
event.type === "ui_envelope" ||
|
||||||
|
event.type === "session_title"
|
||||||
|
) {
|
||||||
|
return updateLastAssistantUiMessage(messages, (message) =>
|
||||||
|
upsertUiDataPart(message, event.type, event.data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "error") {
|
||||||
|
const message = getDataString(event.data, "message") ?? "Agent stream failed";
|
||||||
|
return updateLastAssistantUiMessage(messages, (item) =>
|
||||||
|
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPermissionStatus(reply: AgentPermissionReply): AgentPermissionStatus {
|
||||||
|
if (reply === "always") {
|
||||||
|
return "approved_always";
|
||||||
|
}
|
||||||
|
if (reply === "once") {
|
||||||
|
return "approved_once";
|
||||||
|
}
|
||||||
|
return "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataString(data: unknown, key: string) {
|
||||||
|
if (typeof data !== "object" || data === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (data as Record<string, unknown>)[key];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBodyString(body: unknown, key: string) {
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = (body as Record<string, unknown>)[key];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastAssistantUiMessageId(messages: AgentUiMessage[]) {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (messages[index].role === "assistant") {
|
||||||
|
return messages[index].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentUiMessage(value: unknown): value is AgentUiMessage {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const message = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof message.id === "string" &&
|
||||||
|
(message.role === "user" || message.role === "assistant") &&
|
||||||
|
Array.isArray(message.parts)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof message.id !== "string" ||
|
||||||
|
(message.role !== "user" && message.role !== "assistant")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: AgentUiMessage["parts"] = [];
|
||||||
|
if (typeof message.content === "string" && message.content) {
|
||||||
|
parts.push({ type: "text", text: message.content });
|
||||||
|
}
|
||||||
|
appendLegacyDataParts(parts, "progress", message.progress);
|
||||||
|
appendLegacyDataParts(parts, "permission_request", message.permissions);
|
||||||
|
appendLegacyDataParts(parts, "question_request", message.questions);
|
||||||
|
appendLegacyDataParts(parts, "todo_update", message.todos);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
parts
|
||||||
|
} as AgentUiMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLegacyDataParts(
|
||||||
|
parts: AgentUiMessage["parts"],
|
||||||
|
eventType: string,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
values.forEach((item, index) => {
|
||||||
|
if (typeof item !== "object" || item === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = normalizeLegacyDataPart(eventType, item as Record<string, unknown>);
|
||||||
|
const id =
|
||||||
|
getDataString(data, "id") ??
|
||||||
|
getDataString(data, "request_id") ??
|
||||||
|
`${eventType}-${index}`;
|
||||||
|
parts.push({
|
||||||
|
type: `data-${eventType}`,
|
||||||
|
id,
|
||||||
|
data
|
||||||
|
} as AgentDataPart);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyDataPart(eventType: string, value: Record<string, unknown>) {
|
||||||
|
if (eventType === "progress") {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
started_at: value.started_at ?? value.startedAt,
|
||||||
|
ended_at: value.ended_at ?? value.endedAt,
|
||||||
|
elapsed_ms: value.elapsed_ms ?? value.elapsedMs,
|
||||||
|
duration_ms: value.duration_ms ?? value.durationMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "permission_request" || eventType === "question_request") {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
session_id: value.session_id ?? value.sessionId,
|
||||||
|
request_id: value.request_id ?? value.requestId,
|
||||||
|
created_at: value.created_at ?? value.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "todo_update") {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
session_id: value.session_id ?? value.sessionId,
|
||||||
|
message_id: value.message_id ?? value.messageId,
|
||||||
|
created_at: value.created_at ?? value.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLastAssistantUiMessage(
|
||||||
|
messages: AgentUiMessage[],
|
||||||
|
updater: (message: AgentUiMessage) => AgentUiMessage
|
||||||
|
) {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (messages[index].role === "assistant") {
|
||||||
|
const next = [...messages];
|
||||||
|
next[index] = updater(messages[index]);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...messages, updater(createAssistantUiMessage())];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssistantUiMessage(): AgentUiMessage {
|
||||||
|
return {
|
||||||
|
id: `assistant-${Date.now().toString(36)}`,
|
||||||
|
role: "assistant",
|
||||||
|
parts: []
|
||||||
|
} as AgentUiMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
|
||||||
|
const textIndex = message.parts.findIndex((part) => part.type === "text");
|
||||||
|
if (textIndex === -1) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: [{ type: "text", text }, ...message.parts]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: message.parts.map((part, index) =>
|
||||||
|
index === textIndex && part.type === "text"
|
||||||
|
? { ...part, text: `${part.text}${text}` }
|
||||||
|
: part
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertUiDataPart(
|
||||||
|
message: AgentUiMessage,
|
||||||
|
eventType: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): AgentUiMessage {
|
||||||
|
const type = `data-${eventType}`;
|
||||||
|
const id =
|
||||||
|
getDataString(data, "id") ??
|
||||||
|
getDataString(data, "request_id") ??
|
||||||
|
getDataString(data, "envelope_id");
|
||||||
|
const existingIndex =
|
||||||
|
id === undefined
|
||||||
|
? -1
|
||||||
|
: message.parts.findIndex((part) => part.type === type && "id" in part && part.id === id);
|
||||||
|
const part = (id ? { type, id, data } : { type, data }) as AgentDataPart;
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: [...message.parts, part]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: message.parts.map((item, index) => (index === existingIndex ? part : item))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermissionOverrides(
|
||||||
|
message: AgentChatMessage,
|
||||||
|
permissionOverrides: Record<string, PermissionOverride>
|
||||||
|
) {
|
||||||
|
if (!message.permissions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
permissions: message.permissions.map((permission) => {
|
||||||
|
const override = permissionOverrides[permission.requestId];
|
||||||
|
return override
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: override.status,
|
||||||
|
error: override.error,
|
||||||
|
repliedAt: override.status === "submitting" || override.status === "error" ? permission.repliedAt : Date.now()
|
||||||
|
}
|
||||||
|
: permission;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQuestionOverrides(
|
||||||
|
message: AgentChatMessage,
|
||||||
|
questionOverrides: Record<string, QuestionOverride>
|
||||||
|
) {
|
||||||
|
if (!message.questions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) => {
|
||||||
|
const override = questionOverrides[question.requestId];
|
||||||
|
return override
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: override.status,
|
||||||
|
answers: override.answers ?? question.answers,
|
||||||
|
error: override.error,
|
||||||
|
repliedAt: override.status === "submitting" || override.status === "error" ? question.repliedAt : Date.now()
|
||||||
|
}
|
||||||
|
: question;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMessageText(message: AgentUiMessage) {
|
||||||
|
return message.parts
|
||||||
|
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
|
||||||
|
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
clearMapFeatureInteractionState,
|
||||||
|
setMapFeatureInteractionState,
|
||||||
|
toMapFeatureReference
|
||||||
|
} from "./use-map-interactions";
|
||||||
|
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||||
|
|
||||||
|
describe("map feature interaction state", () => {
|
||||||
|
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
|
||||||
|
|
||||||
|
it("maps selected business features to promoted vector feature references", () => {
|
||||||
|
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||||
|
expect(toMapFeatureReference(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets hover and selected through feature-state", () => {
|
||||||
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
|
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||||
|
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||||
|
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||||
|
{ selected: true, hovered: false }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears only the requested state key", () => {
|
||||||
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
|
clearMapFeatureInteractionState(map, feature, "selected");
|
||||||
|
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||||
|
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||||
|
"selected"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses transparent hit layers for pointer interactions", () => {
|
||||||
|
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
|
||||||
|
"pipes-hit",
|
||||||
|
"junctions-hit",
|
||||||
|
"valves-hit",
|
||||||
|
"reservoirs-hit",
|
||||||
|
"scada-hit"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { DetailFeature } from "../types";
|
||||||
|
import {
|
||||||
|
getFeatureId,
|
||||||
|
getWaterNetworkSourceId,
|
||||||
|
toDetailFeature
|
||||||
|
} from "../map/feature-adapter";
|
||||||
|
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||||
|
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||||
|
|
||||||
|
export type MapFeatureInteractionState = {
|
||||||
|
source: WaterNetworkSourceId;
|
||||||
|
sourceLayer: string;
|
||||||
|
id: string;
|
||||||
|
hovered: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureStateMap = Pick<MapLibreMap, "setFeatureState" | "removeFeatureState">;
|
||||||
|
type FeatureReference = Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id">;
|
||||||
|
|
||||||
|
export function toMapFeatureReference(
|
||||||
|
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||||
|
): FeatureReference | null {
|
||||||
|
if (!feature?.id) return null;
|
||||||
|
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMapFeatureInteractionState(
|
||||||
|
map: FeatureStateMap,
|
||||||
|
feature: FeatureReference,
|
||||||
|
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||||
|
) {
|
||||||
|
map.setFeatureState(
|
||||||
|
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||||
|
state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMapFeatureInteractionState(
|
||||||
|
map: FeatureStateMap,
|
||||||
|
feature: FeatureReference,
|
||||||
|
key?: "hovered" | "selected"
|
||||||
|
) {
|
||||||
|
map.removeFeatureState(
|
||||||
|
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseMapInteractionsOptions = {
|
||||||
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
|
mapReady: boolean;
|
||||||
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
|
selectedFeature: DetailFeature | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMapInteractions({
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
onSelectFeature,
|
||||||
|
selectedFeature
|
||||||
|
}: UseMapInteractionsOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) return;
|
||||||
|
|
||||||
|
let hoveredFeature: FeatureReference | null = null;
|
||||||
|
|
||||||
|
const clearHoveredFeature = () => {
|
||||||
|
if (!hoveredFeature) return;
|
||||||
|
clearMapFeatureInteractionState(map, hoveredFeature, "hovered");
|
||||||
|
hoveredFeature = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
const source = feature && getWaterNetworkSourceId(feature);
|
||||||
|
const id = feature && getFeatureId(feature);
|
||||||
|
if (!source || !id) return;
|
||||||
|
|
||||||
|
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||||
|
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||||
|
clearHoveredFeature();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hoveredFeature?.id === id && hoveredFeature.source === source) return;
|
||||||
|
|
||||||
|
clearHoveredFeature();
|
||||||
|
setMapFeatureInteractionState(map, next, { hovered: true });
|
||||||
|
hoveredFeature = next;
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
clearHoveredFeature();
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitLayerIds = [...INTERACTIVE_HIT_LAYER_IDS];
|
||||||
|
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||||
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
|
map.on("click", hitLayerIds, handleClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearHoveredFeature();
|
||||||
|
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||||
|
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
|
map.off("click", hitLayerIds, handleClick);
|
||||||
|
};
|
||||||
|
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) return;
|
||||||
|
const next = toMapFeatureReference(selectedFeature);
|
||||||
|
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||||
|
return () => {
|
||||||
|
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
||||||
|
};
|
||||||
|
}, [mapReady, mapRef, selectedFeature]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||||
|
|
||||||
|
type UseSimulationLayerVisibilityOptions = {
|
||||||
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
|
mapReady: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSimulationLayerVisibility({
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
visible
|
||||||
|
}: UseSimulationLayerVisibilityOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSimulationLayersVisibility(map, visible);
|
||||||
|
}, [mapRef, mapReady, visible]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { DefaultChatTransport } from "ai";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||||
|
import type {
|
||||||
|
AgentApprovalMode,
|
||||||
|
AgentChatMessage,
|
||||||
|
AgentModelOption,
|
||||||
|
AgentPermissionReply,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentStreamRenderState
|
||||||
|
} from "@/features/agent";
|
||||||
|
import {
|
||||||
|
createAgentApiClient,
|
||||||
|
type AgentSessionStreamEvent
|
||||||
|
} from "@/features/agent/api/client";
|
||||||
|
import {
|
||||||
|
agentBootstrapKey,
|
||||||
|
agentSessionsKey,
|
||||||
|
fetchAgentBootstrap,
|
||||||
|
fetchAgentSessions
|
||||||
|
} from "@/features/agent/api/swr";
|
||||||
|
import {
|
||||||
|
isUiEnvelopeAllowed,
|
||||||
|
parseUiEnvelopePayload,
|
||||||
|
type UIEnvelopePayload,
|
||||||
|
type UIRegistry
|
||||||
|
} from "@/features/agent/ui-envelope";
|
||||||
|
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
||||||
|
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
|
||||||
|
import {
|
||||||
|
applySessionStreamEvent,
|
||||||
|
createCompletedStreamRenderState,
|
||||||
|
getDataString,
|
||||||
|
markLastAssistantStreamDone,
|
||||||
|
markStreamRenderPending,
|
||||||
|
readBodyString,
|
||||||
|
toAgentChatMessages,
|
||||||
|
toAgentUiMessages,
|
||||||
|
toPermissionStatus,
|
||||||
|
type AgentDataPart,
|
||||||
|
type AgentUiMessage,
|
||||||
|
type PermissionOverride,
|
||||||
|
type QuestionOverride
|
||||||
|
} from "./agent-session-message-state";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
||||||
|
const collapseTimerRef = useRef<number | null>(null);
|
||||||
|
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||||
|
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const clientRef = useRef(createAgentApiClient());
|
||||||
|
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 [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||||
|
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||||
|
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||||
|
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
||||||
|
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||||
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
|
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
|
||||||
|
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
|
||||||
|
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
|
||||||
|
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const setApprovalMode = useCallback((mode: AgentApprovalMode) => {
|
||||||
|
approvalModeRef.current = mode;
|
||||||
|
setApprovalModeState(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registryRef.current = registry;
|
||||||
|
}, [registry]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFrontendActionRef.current = onFrontendAction;
|
||||||
|
}, [onFrontendAction]);
|
||||||
|
|
||||||
|
const applySessionId = useCallback((nextSessionId: string | undefined) => {
|
||||||
|
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionIdRef.current = nextSessionId;
|
||||||
|
setSessionId(nextSessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSessionId = useCallback(() => {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
setSessionId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
||||||
|
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const frontendActionExecutor = useMemo(
|
||||||
|
() => new FrontendActionExecutor(
|
||||||
|
(request, signal) => onFrontendActionRef.current(request, signal),
|
||||||
|
(nextSessionId, actionId, result) => clientRef.current.submitFrontendActionResult(nextSessionId, actionId, result)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const handleFrontendAction = useCallback(
|
||||||
|
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
|
||||||
|
[frontendActionExecutor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
|
||||||
|
if (payload.session_id !== activeSessionId) return false;
|
||||||
|
const key = `${activeSessionId}:${payload.envelope_id}`;
|
||||||
|
if (processedEnvelopeIdsRef.current.has(key)) return false;
|
||||||
|
processedEnvelopeIdsRef.current.add(key);
|
||||||
|
if (processedEnvelopeIdsRef.current.size > 256) {
|
||||||
|
const oldest = processedEnvelopeIdsRef.current.values().next().value;
|
||||||
|
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDataPart = useCallback(
|
||||||
|
(part: AgentDataPart) => {
|
||||||
|
const eventSessionId = getDataString(part.data, "session_id");
|
||||||
|
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySessionId(eventSessionId);
|
||||||
|
|
||||||
|
if (part.type === "data-stream_token") {
|
||||||
|
markStreamRenderPending(
|
||||||
|
part.data,
|
||||||
|
chatRef.current.messages,
|
||||||
|
setStreamRenderState
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "data-progress") {
|
||||||
|
const title = getDataString(part.data, "title");
|
||||||
|
if (title) {
|
||||||
|
setStatusLabel(title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
|
||||||
|
|
||||||
|
if (part.type === "data-session_title") {
|
||||||
|
const title = getDataString(part.data, "title");
|
||||||
|
if (title) {
|
||||||
|
setSessionTitle(title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type !== "data-ui_envelope") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseUiEnvelopePayload(part.data);
|
||||||
|
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
|
||||||
|
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "warning",
|
||||||
|
title: "Agent 展示已降级",
|
||||||
|
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||||
|
|
||||||
|
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent UIEnvelope 处理失败",
|
||||||
|
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = useMemo(
|
||||||
|
() =>
|
||||||
|
new DefaultChatTransport<AgentUiMessage>({
|
||||||
|
api: "/api/v1/agent/chat/stream",
|
||||||
|
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
...body,
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
trigger,
|
||||||
|
messageId,
|
||||||
|
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
|
||||||
|
approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
|
||||||
|
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const chat = useChat<AgentUiMessage>({
|
||||||
|
transport,
|
||||||
|
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||||
|
onError: (error) => {
|
||||||
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
setRuntimeAvailability("failed");
|
||||||
|
setStatusLabel("Agent 后端请求失败");
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-stream-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 请求失败",
|
||||||
|
message: error.message || "请确认后端服务已启动。"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
setStatusLabel("Agent 分析完成");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const chatRef = useRef(chat);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatRef.current = chat;
|
||||||
|
}, [chat]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: backendConnection,
|
||||||
|
error: backendConnectionError
|
||||||
|
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
|
||||||
|
shouldRetryOnError: false
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: sessionHistory = [],
|
||||||
|
isLoading: sessionHistoryLoading,
|
||||||
|
isValidating: sessionHistoryValidating,
|
||||||
|
mutate: mutateSessionHistory
|
||||||
|
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearCollapseTimer = useCallback(() => {
|
||||||
|
if (collapseTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(collapseTimerRef.current);
|
||||||
|
collapseTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearMobileCollapseTimer = useCallback(() => {
|
||||||
|
if (mobileCollapseTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(mobileCollapseTimerRef.current);
|
||||||
|
mobileCollapseTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopSessionStreamSubscription = useCallback(() => {
|
||||||
|
sessionStreamAbortRef.current?.abort();
|
||||||
|
sessionStreamAbortRef.current = null;
|
||||||
|
setResumedStreaming(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearCollapseTimer();
|
||||||
|
clearMobileCollapseTimer();
|
||||||
|
stopSessionStreamSubscription();
|
||||||
|
frontendActionExecutor.cancelAll();
|
||||||
|
chatRef.current.stop();
|
||||||
|
};
|
||||||
|
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backendConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registryRef.current = backendConnection.registry;
|
||||||
|
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
|
||||||
|
setRegistry(backendConnection.registry);
|
||||||
|
setModelOptions(backendConnection.models);
|
||||||
|
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||||
|
if (chatRef.current.status === "ready") {
|
||||||
|
setRuntimeAvailability("connected");
|
||||||
|
setStatusLabel("准备就绪");
|
||||||
|
}
|
||||||
|
}, [backendConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backendConnectionError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRuntimeAvailability("unavailable");
|
||||||
|
setStatusLabel("Agent 后端不可用");
|
||||||
|
}, [backendConnectionError]);
|
||||||
|
|
||||||
|
const collapsePanel = useCallback(() => {
|
||||||
|
clearCollapseTimer();
|
||||||
|
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
setPanelOpen(false);
|
||||||
|
setPanelCollapsing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPanelCollapsing(true);
|
||||||
|
collapseTimerRef.current = window.setTimeout(() => {
|
||||||
|
setPanelOpen(false);
|
||||||
|
setPanelCollapsing(false);
|
||||||
|
collapseTimerRef.current = null;
|
||||||
|
}, AGENT_PANEL_COLLAPSE_MS);
|
||||||
|
}, [clearCollapseTimer]);
|
||||||
|
|
||||||
|
const expandPanel = useCallback(() => {
|
||||||
|
clearCollapseTimer();
|
||||||
|
setPanelCollapsing(false);
|
||||||
|
setPanelOpen(true);
|
||||||
|
}, [clearCollapseTimer]);
|
||||||
|
|
||||||
|
const openMobilePanel = useCallback(() => {
|
||||||
|
clearMobileCollapseTimer();
|
||||||
|
setMobilePanelCollapsing(false);
|
||||||
|
setMobileOpen(true);
|
||||||
|
}, [clearMobileCollapseTimer]);
|
||||||
|
|
||||||
|
const closeMobilePanel = useCallback(() => {
|
||||||
|
clearMobileCollapseTimer();
|
||||||
|
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
setMobilePanelCollapsing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMobilePanelCollapsing(true);
|
||||||
|
mobileCollapseTimerRef.current = window.setTimeout(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
setMobilePanelCollapsing(false);
|
||||||
|
mobileCollapseTimerRef.current = null;
|
||||||
|
}, AGENT_PANEL_COLLAPSE_MS);
|
||||||
|
}, [clearMobileCollapseTimer]);
|
||||||
|
|
||||||
|
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
|
||||||
|
const messages = useMemo(
|
||||||
|
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
|
||||||
|
[chat.messages, permissionOverrides, questionOverrides]
|
||||||
|
);
|
||||||
|
const personaState = useMemo(
|
||||||
|
() => deriveAgentPersonaState(messages, {
|
||||||
|
chatStatus: chat.status,
|
||||||
|
runtimeAvailability,
|
||||||
|
statusLabel
|
||||||
|
}),
|
||||||
|
[chat.status, messages, runtimeAvailability, statusLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshSessionHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await mutateSessionHistory();
|
||||||
|
} catch (error) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-history-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 历史记录加载失败",
|
||||||
|
message: error instanceof Error ? error.message : "无法获取 Agent 会话历史。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mutateSessionHistory]);
|
||||||
|
|
||||||
|
const handleSessionStreamEvent = useCallback(
|
||||||
|
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
|
||||||
|
if (event.type === "state") {
|
||||||
|
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMessages = toAgentUiMessages(event.messages);
|
||||||
|
chatRef.current.setMessages(nextMessages);
|
||||||
|
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||||
|
setResumedStreaming(event.isStreaming);
|
||||||
|
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSessionId = event.sessionId;
|
||||||
|
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session_title") {
|
||||||
|
const title = getDataString(event.data, "title");
|
||||||
|
if (title) {
|
||||||
|
setSessionTitle(title);
|
||||||
|
}
|
||||||
|
} else if (event.type === "token") {
|
||||||
|
markStreamRenderPending(
|
||||||
|
event.data,
|
||||||
|
chatRef.current.messages,
|
||||||
|
setStreamRenderState
|
||||||
|
);
|
||||||
|
} else if (event.type === "ui_envelope") {
|
||||||
|
const payload = parseUiEnvelopePayload(event.data);
|
||||||
|
const activeSessionId = eventSessionId ?? sessionIdRef.current;
|
||||||
|
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "warning",
|
||||||
|
title: "Agent 展示已降级",
|
||||||
|
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||||
|
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||||
|
showMapNotice({
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent UIEnvelope 处理失败",
|
||||||
|
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (event.type === "frontend_action") {
|
||||||
|
void handleFrontendAction(event.data);
|
||||||
|
} else if (event.type === "progress") {
|
||||||
|
const title = getDataString(event.data, "title");
|
||||||
|
if (title) {
|
||||||
|
setStatusLabel(title);
|
||||||
|
}
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
setResumedStreaming(false);
|
||||||
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
setStatusLabel("Agent 分析完成");
|
||||||
|
void refreshSessionHistory();
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
setResumedStreaming(false);
|
||||||
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
setStatusLabel("Agent 后端请求失败");
|
||||||
|
void refreshSessionHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
|
||||||
|
},
|
||||||
|
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startSessionStreamSubscription = useCallback(
|
||||||
|
(nextSessionId: string) => {
|
||||||
|
stopSessionStreamSubscription();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
sessionStreamAbortRef.current = controller;
|
||||||
|
setResumedStreaming(true);
|
||||||
|
|
||||||
|
void clientRef.current
|
||||||
|
.streamSession(nextSessionId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: (event) => handleSessionStreamEvent(event, nextSessionId)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setRuntimeAvailability("failed");
|
||||||
|
setStatusLabel("Agent 会话流恢复失败");
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-stream-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 会话流恢复失败",
|
||||||
|
message: error instanceof Error ? error.message : "无法恢复这个运行中的会话。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (sessionStreamAbortRef.current === controller) {
|
||||||
|
sessionStreamAbortRef.current = null;
|
||||||
|
setResumedStreaming(false);
|
||||||
|
void refreshSessionHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleSessionStreamEvent, refreshSessionHistory, stopSessionStreamSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadHistorySession = useCallback(
|
||||||
|
async (nextSessionId: string) => {
|
||||||
|
if (streaming) {
|
||||||
|
chat.stop();
|
||||||
|
stopSessionStreamSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadedSession = await clientRef.current.loadSession(nextSessionId);
|
||||||
|
if (!loadedSession) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-history-status",
|
||||||
|
tone: "warning",
|
||||||
|
title: "Agent 历史记录不可用",
|
||||||
|
message: "这个历史会话不存在或已被删除。"
|
||||||
|
});
|
||||||
|
await refreshSessionHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySessionId(loadedSession.sessionId);
|
||||||
|
setSessionTitle(loadedSession.title);
|
||||||
|
setStatusLabel("Agent 历史会话已打开");
|
||||||
|
setPermissionOverrides({});
|
||||||
|
setQuestionOverrides({});
|
||||||
|
const nextMessages = toAgentUiMessages(loadedSession.messages);
|
||||||
|
chat.setMessages(nextMessages);
|
||||||
|
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||||
|
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
|
||||||
|
startSessionStreamSubscription(loadedSession.sessionId);
|
||||||
|
}
|
||||||
|
await refreshSessionHistory();
|
||||||
|
} catch (error) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-history-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 历史记录打开失败",
|
||||||
|
message: error instanceof Error ? error.message : "无法打开这个历史会话。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameHistorySession = useCallback(
|
||||||
|
async (nextSessionId: string, title: string) => {
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
showMapNotice({ tone: "warning", message: "对话主题不能为空。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
|
||||||
|
isTitleManuallyEdited: true
|
||||||
|
});
|
||||||
|
await mutateSessionHistory(
|
||||||
|
(current = []) =>
|
||||||
|
current.map((session) =>
|
||||||
|
session.id === nextSessionId
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
title: normalizedTitle,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
: session
|
||||||
|
),
|
||||||
|
{ revalidate: false }
|
||||||
|
);
|
||||||
|
if (sessionIdRef.current === nextSessionId) {
|
||||||
|
setSessionTitle(normalizedTitle);
|
||||||
|
}
|
||||||
|
await refreshSessionHistory();
|
||||||
|
} catch (error) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-history-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 对话重命名失败",
|
||||||
|
message: error instanceof Error ? error.message : "无法更新这个对话主题。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutateSessionHistory, refreshSessionHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetDraftSession = useCallback(
|
||||||
|
(nextStatusLabel = "Agent 新对话已准备") => {
|
||||||
|
clearSessionId();
|
||||||
|
setSessionTitle("新对话");
|
||||||
|
setStatusLabel(nextStatusLabel);
|
||||||
|
setRuntimeAvailability("connected");
|
||||||
|
setPermissionOverrides({});
|
||||||
|
setQuestionOverrides({});
|
||||||
|
setStreamRenderState({});
|
||||||
|
chat.setMessages([]);
|
||||||
|
},
|
||||||
|
[chat, clearSessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteHistorySession = useCallback(
|
||||||
|
async (nextSessionId: string) => {
|
||||||
|
if (streaming) {
|
||||||
|
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.deleteSession(nextSessionId);
|
||||||
|
await mutateSessionHistory(
|
||||||
|
(current = []) => current.filter((session) => session.id !== nextSessionId),
|
||||||
|
{ revalidate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionIdRef.current === nextSessionId) {
|
||||||
|
resetDraftSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessionHistory();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-history-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 历史记录删除失败",
|
||||||
|
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startNewSession = useCallback(async () => {
|
||||||
|
if (chat.status === "submitted" || chat.status === "streaming") {
|
||||||
|
chat.stop();
|
||||||
|
}
|
||||||
|
stopSessionStreamSubscription();
|
||||||
|
|
||||||
|
resetDraftSession();
|
||||||
|
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
|
||||||
|
|
||||||
|
const submitPrompt = useCallback(
|
||||||
|
async (prompt: string) => {
|
||||||
|
const normalizedPrompt = prompt.trim();
|
||||||
|
if (!normalizedPrompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streaming) {
|
||||||
|
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusLabel("Agent 正在分析");
|
||||||
|
|
||||||
|
await chat.sendMessage(
|
||||||
|
{ text: normalizedPrompt },
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
session_id: sessionIdRef.current ?? undefined,
|
||||||
|
model: selectedModel || undefined,
|
||||||
|
approval_mode: approvalMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[approvalMode, chat, selectedModel, streaming]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopPrompt = useCallback(() => {
|
||||||
|
chat.stop();
|
||||||
|
stopSessionStreamSubscription();
|
||||||
|
const activeSessionId = sessionIdRef.current;
|
||||||
|
if (activeSessionId) {
|
||||||
|
void clientRef.current.abort(activeSessionId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||||
|
}, [chat, stopSessionStreamSubscription]);
|
||||||
|
|
||||||
|
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
|
||||||
|
setPermissionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[permission.requestId]: { status: "submitting" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.replyPermission(permission.requestId, {
|
||||||
|
sessionId: permission.sessionId,
|
||||||
|
reply
|
||||||
|
});
|
||||||
|
setPermissionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[permission.requestId]: {
|
||||||
|
status: toPermissionStatus(reply)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "权限请求提交失败。";
|
||||||
|
setPermissionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[permission.requestId]: {
|
||||||
|
status: "error",
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-permission-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 权限提交失败",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: { status: "submitting" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.replyQuestion(question.requestId, {
|
||||||
|
sessionId: question.sessionId,
|
||||||
|
answers
|
||||||
|
});
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: {
|
||||||
|
status: "answered",
|
||||||
|
answers
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "问题回复提交失败。";
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: {
|
||||||
|
status: "error",
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-question-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 问题提交失败",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: { status: "submitting" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.rejectQuestion(question.requestId, {
|
||||||
|
sessionId: question.sessionId
|
||||||
|
});
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: {
|
||||||
|
status: "rejected"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "问题跳过提交失败。";
|
||||||
|
setQuestionOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[question.requestId]: {
|
||||||
|
status: "error",
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
showMapNotice({
|
||||||
|
id: "agent-question-status",
|
||||||
|
tone: "error",
|
||||||
|
title: "Agent 问题提交失败",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalMode,
|
||||||
|
collapsePanel,
|
||||||
|
expandPanel,
|
||||||
|
mobileOpen,
|
||||||
|
mobilePanelCollapsing,
|
||||||
|
messages,
|
||||||
|
modelOptions,
|
||||||
|
openMobilePanel,
|
||||||
|
closeMobilePanel,
|
||||||
|
panelCollapsing,
|
||||||
|
panelOpen,
|
||||||
|
personaState,
|
||||||
|
refreshSessionHistory,
|
||||||
|
startNewSession,
|
||||||
|
loadHistorySession,
|
||||||
|
renameHistorySession,
|
||||||
|
deleteHistorySession,
|
||||||
|
sessionHistory,
|
||||||
|
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
||||||
|
sessionId,
|
||||||
|
sessionTitle,
|
||||||
|
statusLabel,
|
||||||
|
streamRenderState,
|
||||||
|
selectedModel,
|
||||||
|
setApprovalMode,
|
||||||
|
setSelectedModel,
|
||||||
|
stopPrompt,
|
||||||
|
streaming,
|
||||||
|
rejectQuestion,
|
||||||
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
resolveRenderRef,
|
||||||
|
submitPrompt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAgentPersonaState(
|
||||||
|
messages: AgentChatMessage[],
|
||||||
|
{
|
||||||
|
chatStatus,
|
||||||
|
runtimeAvailability,
|
||||||
|
statusLabel
|
||||||
|
}: {
|
||||||
|
chatStatus: string;
|
||||||
|
runtimeAvailability: AgentRuntimeAvailability;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
): PersonaState {
|
||||||
|
if (
|
||||||
|
runtimeAvailability === "unavailable" ||
|
||||||
|
runtimeAvailability === "failed" ||
|
||||||
|
/失败|不可用|错误|降级/.test(statusLabel)
|
||||||
|
) {
|
||||||
|
return "asleep";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingOperatorInput(messages)) {
|
||||||
|
return "listening";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
chatStatus === "submitted" ||
|
||||||
|
chatStatus === "streaming" ||
|
||||||
|
hasRunningSessionWork(messages) ||
|
||||||
|
runtimeAvailability === "connecting"
|
||||||
|
) {
|
||||||
|
return "thinking";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
|
import {
|
||||||
|
DRAWING_INTERACTIVE_LAYER_IDS,
|
||||||
|
DRAWING_SOURCE_IDS,
|
||||||
|
createCircleFeature,
|
||||||
|
createLineFeature,
|
||||||
|
createPointFeature,
|
||||||
|
createPolygonFeature,
|
||||||
|
emptyDrawingFeatureCollection,
|
||||||
|
ensureDrawingLayers,
|
||||||
|
setSelectedDrawingFeatureId,
|
||||||
|
setDrawingSourceData,
|
||||||
|
type DrawingFeatureCollection
|
||||||
|
} from "../map/drawing-layers";
|
||||||
|
import {
|
||||||
|
createDrawingId,
|
||||||
|
createDrawingLabel,
|
||||||
|
createFeatureFromDraft,
|
||||||
|
formatDrawingTime,
|
||||||
|
getDraftPointCount,
|
||||||
|
getDrawingKindLabel,
|
||||||
|
getVisibleFeatureCollection,
|
||||||
|
type DrawingDraftState,
|
||||||
|
type WorkbenchDrawMode
|
||||||
|
} from "../map/drawing-model";
|
||||||
|
import { getDistanceMeters, isSameCoordinate } from "../map/geo";
|
||||||
|
|
||||||
|
export type { WorkbenchDrawMode };
|
||||||
|
|
||||||
|
export type WorkbenchDrawingAnnotation = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
kind: WorkbenchDrawMode;
|
||||||
|
hidden: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseWorkbenchDrawingOptions = {
|
||||||
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
|
mapReady: boolean;
|
||||||
|
activeMode: WorkbenchDrawMode | null;
|
||||||
|
onModeChange: (mode: WorkbenchDrawMode | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkbenchDrawing({
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
activeMode,
|
||||||
|
onModeChange
|
||||||
|
}: UseWorkbenchDrawingOptions) {
|
||||||
|
const [featureCollection, setFeatureCollection] = useState<DrawingFeatureCollection>(emptyDrawingFeatureCollection);
|
||||||
|
const [hiddenFeatureIds, setHiddenFeatureIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
||||||
|
const [, setHistoryVersion] = useState(0);
|
||||||
|
const [draftPointCount, setDraftPointCount] = useState(0);
|
||||||
|
const featureCollectionRef = useRef(featureCollection);
|
||||||
|
const hiddenFeatureIdsRef = useRef(hiddenFeatureIds);
|
||||||
|
const undoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||||
|
const redoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||||
|
const draftRef = useRef<DrawingDraftState | null>(null);
|
||||||
|
const featureCounterRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
featureCollectionRef.current = featureCollection;
|
||||||
|
}, [featureCollection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hiddenFeatureIdsRef.current = hiddenFeatureIds;
|
||||||
|
}, [hiddenFeatureIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDrawingLayers(map);
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollectionRef.current, hiddenFeatureIdsRef.current));
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||||
|
}, [mapReady, mapRef]);
|
||||||
|
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
draftRef.current = null;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map) {
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||||
|
}
|
||||||
|
}, [mapRef]);
|
||||||
|
|
||||||
|
const updateFeatures = useCallback(
|
||||||
|
(nextCollection: DrawingFeatureCollection) => {
|
||||||
|
featureCollectionRef.current = nextCollection;
|
||||||
|
setFeatureCollection(nextCollection);
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map) {
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(nextCollection, hiddenFeatureIdsRef.current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mapRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitFeatures = useCallback(
|
||||||
|
(nextCollection: DrawingFeatureCollection) => {
|
||||||
|
undoStackRef.current.push(featureCollectionRef.current);
|
||||||
|
redoStackRef.current = [];
|
||||||
|
updateFeatures(nextCollection);
|
||||||
|
setHistoryVersion((current) => current + 1);
|
||||||
|
},
|
||||||
|
[updateFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollection, hiddenFeatureIds));
|
||||||
|
}, [featureCollection, hiddenFeatureIds, mapReady, mapRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDrawingFeatureId(map, selectedFeatureId);
|
||||||
|
}, [mapReady, mapRef, selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map || activeMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMap = map;
|
||||||
|
|
||||||
|
function handleDrawingClick(event: MapMouseEvent) {
|
||||||
|
const feature = activeMap.queryRenderedFeatures(event.point, {
|
||||||
|
layers: DRAWING_INTERACTIVE_LAYER_IDS
|
||||||
|
})[0];
|
||||||
|
const id = feature?.properties?.id;
|
||||||
|
if (typeof id === "string" && id) {
|
||||||
|
setSelectedFeatureId((current) => (current === id ? null : id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
activeMap.getCanvas().style.cursor = "pointer";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
activeMap.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||||
|
if (activeMap.getLayer(layerId)) {
|
||||||
|
activeMap.on("click", layerId, handleDrawingClick);
|
||||||
|
activeMap.on("mouseenter", layerId, handleMouseEnter);
|
||||||
|
activeMap.on("mouseleave", layerId, handleMouseLeave);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||||
|
if (activeMap.getLayer(layerId)) {
|
||||||
|
activeMap.off("click", layerId, handleDrawingClick);
|
||||||
|
activeMap.off("mouseenter", layerId, handleMouseEnter);
|
||||||
|
activeMap.off("mouseleave", layerId, handleMouseLeave);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [activeMode, mapReady, mapRef]);
|
||||||
|
|
||||||
|
const updateDraft = useCallback(
|
||||||
|
(draft: DrawingDraftState | null) => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft || getDraftPointCount(draft) === 0) {
|
||||||
|
setDraftPointCount(0);
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftPointCount(getDraftPointCount(draft));
|
||||||
|
|
||||||
|
const draftFeatures: DrawingFeatureCollection["features"] =
|
||||||
|
draft.mode === "circle"
|
||||||
|
? [createPointFeature("draft-circle-center", draft.center, "圆心")]
|
||||||
|
: draft.coordinates.map((coordinates, index) =>
|
||||||
|
createPointFeature(`draft-vertex-${index}`, coordinates, `顶点 ${index + 1}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draft.mode !== "circle" && draft.coordinates.length >= 2) {
|
||||||
|
draftFeatures.push(createLineFeature("draft-line", draft.coordinates, "绘制草稿"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.mode === "polygon" && draft.coordinates.length >= 3) {
|
||||||
|
draftFeatures.push(createPolygonFeature("draft-polygon", draft.coordinates, "范围草稿"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.mode === "circle" && draft.edge) {
|
||||||
|
draftFeatures.push(createPointFeature("draft-circle-edge", draft.edge, "半径"));
|
||||||
|
draftFeatures.push(createLineFeature("draft-circle-radius", [draft.center, draft.edge], "圆形半径"));
|
||||||
|
draftFeatures.push(createCircleFeature("draft-circle", draft.center, getDistanceMeters(draft.center, draft.edge), "圆形草稿"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: draftFeatures
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mapRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishDraft = useCallback(() => {
|
||||||
|
const draft = draftRef.current;
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumPoints = draft.mode === "line" ? 2 : draft.mode === "polygon" ? 3 : 2;
|
||||||
|
const draftPointCount = getDraftPointCount(draft);
|
||||||
|
if (draftPointCount < minimumPoints) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = createDrawingId(draft.mode, featureCounterRef.current + 1);
|
||||||
|
featureCounterRef.current += 1;
|
||||||
|
const label = createDrawingLabel(draft.mode, featureCounterRef.current);
|
||||||
|
const feature = createFeatureFromDraft(id, draft, label);
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitFeatures({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [...featureCollectionRef.current.features, feature]
|
||||||
|
});
|
||||||
|
updateDraft(null);
|
||||||
|
onModeChange(null);
|
||||||
|
}, [commitFeatures, onModeChange, updateDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map || !activeMode) {
|
||||||
|
clearDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingMode = activeMode;
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
const previousCursor = canvas.style.cursor;
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
|
||||||
|
if (drawingMode !== "point") {
|
||||||
|
map.doubleClickZoom.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MapMouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const coordinates: [number, number] = [event.lngLat.lng, event.lngLat.lat];
|
||||||
|
|
||||||
|
if (drawingMode === "point") {
|
||||||
|
const nextNumber = featureCounterRef.current + 1;
|
||||||
|
const id = createDrawingId(drawingMode, nextNumber);
|
||||||
|
featureCounterRef.current = nextNumber;
|
||||||
|
const feature = createPointFeature(id, coordinates, createDrawingLabel("point", nextNumber));
|
||||||
|
commitFeatures({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [...featureCollectionRef.current.features, feature]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawingMode === "circle") {
|
||||||
|
const currentDraft = draftRef.current?.mode === "circle" ? draftRef.current : null;
|
||||||
|
if (!currentDraft) {
|
||||||
|
updateDraft({
|
||||||
|
mode: "circle",
|
||||||
|
center: coordinates
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraft({
|
||||||
|
mode: "circle",
|
||||||
|
center: currentDraft.center,
|
||||||
|
edge: coordinates
|
||||||
|
});
|
||||||
|
finishDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftMode: DrawingDraftState["mode"] = drawingMode === "polygon" ? "polygon" : "line";
|
||||||
|
const currentDraft = draftRef.current?.mode === draftMode ? draftRef.current : { mode: draftMode, coordinates: [] };
|
||||||
|
if (isSameCoordinate(currentDraft.coordinates[currentDraft.coordinates.length - 1], coordinates)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraft({
|
||||||
|
mode: draftMode,
|
||||||
|
coordinates: [...currentDraft.coordinates, coordinates]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick(event: MapMouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
finishDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event: MapMouseEvent) {
|
||||||
|
if (drawingMode !== "circle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDraft = draftRef.current;
|
||||||
|
if (currentDraft?.mode !== "circle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraft({
|
||||||
|
mode: "circle",
|
||||||
|
center: currentDraft.center,
|
||||||
|
edge: [event.lngLat.lng, event.lngLat.lat]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", handleClick);
|
||||||
|
map.on("dblclick", handleDoubleClick);
|
||||||
|
map.on("mousemove", handleMouseMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", handleClick);
|
||||||
|
map.off("dblclick", handleDoubleClick);
|
||||||
|
map.off("mousemove", handleMouseMove);
|
||||||
|
canvas.style.cursor = previousCursor;
|
||||||
|
|
||||||
|
if (drawingMode !== "point") {
|
||||||
|
map.doubleClickZoom.enable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeMode, clearDraft, commitFeatures, finishDraft, mapReady, mapRef, updateDraft]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
const draft = draftRef.current;
|
||||||
|
if (draft && getDraftPointCount(draft) > 0) {
|
||||||
|
if (draft.mode === "circle") {
|
||||||
|
updateDraft(draft.edge ? { mode: "circle", center: draft.center } : null);
|
||||||
|
} else {
|
||||||
|
updateDraft({
|
||||||
|
mode: draft.mode,
|
||||||
|
coordinates: draft.coordinates.slice(0, -1)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCollection = undoStackRef.current.pop();
|
||||||
|
if (!previousCollection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redoStackRef.current.push(featureCollectionRef.current);
|
||||||
|
updateFeatures(previousCollection);
|
||||||
|
setHistoryVersion((current) => current + 1);
|
||||||
|
}, [updateDraft, updateFeatures]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
const nextCollection = redoStackRef.current.pop();
|
||||||
|
if (!nextCollection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
undoStackRef.current.push(featureCollectionRef.current);
|
||||||
|
updateFeatures(nextCollection);
|
||||||
|
setHistoryVersion((current) => current + 1);
|
||||||
|
}, [updateFeatures]);
|
||||||
|
|
||||||
|
const deleteSelected = useCallback(() => {
|
||||||
|
if (!selectedFeatureId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFeatures = featureCollectionRef.current.features.filter((feature) => feature.properties.id !== selectedFeatureId);
|
||||||
|
if (nextFeatures.length === featureCollectionRef.current.features.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFeatureId(null);
|
||||||
|
setHiddenFeatureIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(selectedFeatureId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
commitFeatures({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: nextFeatures
|
||||||
|
});
|
||||||
|
}, [commitFeatures, selectedFeatureId]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
clearDraft();
|
||||||
|
setHiddenFeatureIds(new Set());
|
||||||
|
setSelectedFeatureId(null);
|
||||||
|
commitFeatures(emptyDrawingFeatureCollection);
|
||||||
|
onModeChange(null);
|
||||||
|
}, [clearDraft, commitFeatures, onModeChange]);
|
||||||
|
|
||||||
|
const toggleVisibility = useCallback((featureId: string) => {
|
||||||
|
setHiddenFeatureIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(featureId)) {
|
||||||
|
next.delete(featureId);
|
||||||
|
} else {
|
||||||
|
next.add(featureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exportGeoJSON = useCallback(() => {
|
||||||
|
const data = JSON.stringify(featureCollectionRef.current, null, 2);
|
||||||
|
const blob = new Blob([data], { type: "application/geo+json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "workbench-annotations.geojson";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const annotations = useMemo<WorkbenchDrawingAnnotation[]>(
|
||||||
|
() =>
|
||||||
|
featureCollection.features.map((feature) => ({
|
||||||
|
id: feature.properties.id,
|
||||||
|
label: feature.properties.label,
|
||||||
|
description: `${getDrawingKindLabel(feature.properties.kind)} · ${formatDrawingTime(feature.properties.createdAt)}`,
|
||||||
|
kind: feature.properties.kind,
|
||||||
|
hidden: hiddenFeatureIds.has(feature.properties.id),
|
||||||
|
selected: selectedFeatureId === feature.properties.id,
|
||||||
|
onToggleVisibility: () => toggleVisibility(feature.properties.id),
|
||||||
|
onSelect: () => setSelectedFeatureId(feature.properties.id)
|
||||||
|
})),
|
||||||
|
[featureCollection, hiddenFeatureIds, selectedFeatureId, toggleVisibility]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
annotations,
|
||||||
|
canUndo: Boolean(draftPointCount || undoStackRef.current.length),
|
||||||
|
canRedo: Boolean(redoStackRef.current.length),
|
||||||
|
canDeleteSelected: Boolean(selectedFeatureId),
|
||||||
|
hasFeatures: featureCollection.features.length > 0,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
deleteSelected,
|
||||||
|
clear,
|
||||||
|
exportGeoJSON
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user