feat: migrate drainage frontend to Vite

This commit is contained in:
2026-07-22 12:47:36 +08:00
parent e0cfa3d6eb
commit 72850761ce
213 changed files with 5726 additions and 5283 deletions
@@ -34,4 +34,4 @@ Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy correc
- Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning.
- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels.
- Preserve list identity and scroll position when a collapsed list expands into a detail workspace.
- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
- Validate frontend UI changes with `pnpm lint` and `pnpm typecheck`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
coverage
playwright-report
test-results
.env
.env.*
*.log
+11 -5
View File
@@ -1,5 +1,11 @@
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver
NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou
NEXT_PUBLIC_ENABLE_DEV_PANEL=false
DRAINAGE_MAPBOX_ACCESS_TOKEN=
DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
DRAINAGE_TTS_API_URL=/api/tts/edge
DRAINAGE_ENABLE_DEV_PANEL=false
DRAINAGE_ENABLE_MSW=false
# Optional development proxy target. This value is not exposed to browser code.
AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787
TTS_API_INTERNAL_BASE_URL=http://127.0.0.1:8790
+12 -6
View File
@@ -1,10 +1,16 @@
.DS_Store
.env.local
.next/
node_modules/
playwright-report/
test-results/
tsconfig.tsbuildinfo
node_modules
.next
dist
coverage
playwright-report
test-results
.env
.env.*
!.env.example
*.log
*.tsbuildinfo
*:Zone.Identifier
供水管网_Agent_WebGIS_设计与技术框架.md
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
todo.md
+36 -36
View File
@@ -1,6 +1,6 @@
# 亚克力设计类别与组件使用清单
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `app/globals.css` 为准,组件映射以 `features/` 下的现有代码为准。
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
## 总览
@@ -25,7 +25,7 @@
- 特征:保留底部分隔和下投影,不使用顶部高光。
- 当前组件:
- `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`features/workbench/components/workbench-top-bar.tsx`
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`
### `acrylic-panel`
@@ -42,15 +42,15 @@
- `AlertMenu``ScenarioMenu``UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。
- 直接或间接涉及文件:
- `features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-collapsed-rail.tsx`
- `features/map/core/components/map-control-styles.ts`
- `features/map/core/components/notice.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/feature-popover.tsx`
- `features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/map-dev-panel.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/map/core/components/map-control-styles.ts`
- `src/features/map/core/components/notice.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/feature-popover.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/map-dev-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control`
@@ -64,7 +64,7 @@
- `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`features/map/core/components/map-control-styles.ts`
- 共享定义:`src/features/map/core/components/map-control-styles.ts`
### `glass-transient`
@@ -72,7 +72,7 @@
- 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件:
- `AgentTaskTicker` 的活动任务卡片。
- 文件:`features/workbench/components/agent-task-ticker.tsx`
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`
## 2. 内部实体表面
@@ -82,7 +82,7 @@
### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
@@ -101,17 +101,17 @@
- Header 下拉菜单的内部控制区。
- 通用 `IconButton`
- 主要文件:
- `features/agent/components/agent-collapsed-rail.tsx`
- `features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-operational-brief.tsx`
- `features/map/core/components/control-panel.tsx`
- `features/map/core/components/layer-control.tsx`
- `features/map/core/components/measure-panel.tsx`
- `features/workbench/components/agent-task-ticker.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
- `features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/map/core/components/control-panel.tsx`
- `src/features/map/core/components/layer-control.tsx`
- `src/features/map/core/components/measure-panel.tsx`
- `src/features/workbench/components/agent-task-ticker.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well`
@@ -132,16 +132,16 @@
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-message-details.tsx`
- `features/agent/components/agent-operational-brief.tsx`
- `features/agent/components/agent-ui-envelope-renderer.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
- `features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/feature-property-list.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx`
- `features/map/core/components/base-layers-control.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/feature-property-list.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面
@@ -192,7 +192,7 @@
## 共享类映射
地图核心组件通过 `features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
| 共享常量 | 实际材质类 | 典型用途 |
|---|---|---|
+70 -110
View File
@@ -1,130 +1,90 @@
# Repository Guidelines
## Project Structure & Module Organization
## Project Structure
This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS.
This repository is a Vite React single-page business system for an Agent-driven
drainage-network WebGIS. Runtime TypeScript lives under `src/`.
- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin.
- `lib/`: shared utilities that are not owned by a feature.
- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows.
- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here.
- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly.
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features.
- Root markdown files are product/design references and should not be treated as runtime code.
- `src/app/`: application shell and providers.
- `src/features/map/core/`: reusable map UI and generic map helpers. This layer
must not know drainage-network business layer IDs or Agent workflows.
- `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
adapters, scenarios, and workbench-specific interactions.
- `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
recommendations.
- `src/shared/`: reusable UI, AI elements, runtime configuration, and utilities.
- `src/mocks/`: MSW handlers and fixtures.
- `tests/browser/`: product-specific Playwright regressions.
- `public/`: static drainage and SCADA assets plus the runtime-config placeholder.
## Build, Test, and Development Commands
Do not add Next.js APIs or route handlers. Backend and BFF behavior belongs in
the Agent service or another service. Browser-safe GeoServer reads may happen
directly when CORS is enabled.
## Commands
Use `pnpm`.
```bash
pnpm dev
```
Starts the local development server at `http://localhost:3000`.
```bash
pnpm build
pnpm typecheck
pnpm lint
pnpm test
pnpm test:browser
```
Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes.
The Vite development server listens on `http://127.0.0.1:5173` by default.
`pnpm build` runs TypeScript validation before producing `dist/`.
## Coding Style
- Use TypeScript and React function components with explicit exported types at
module boundaries.
- Use two-space indentation and kebab-case filenames.
- Use PascalCase for components and types, camelCase for functions and values,
and `useXxx` for hooks.
- Keep orchestration components thin. Move sources, layers, event handling, and
feature adapters into focused modules.
- Keep MapLibre sources, layers, camera helpers, and interactions outside
presentational components.
- Keep Agent rendering schema-driven. Validate Agent output before it reaches
React, and represent map actions as typed data that can be previewed,
confirmed, applied, and rolled back.
## Domain Boundaries
- `src/features/map/core` owns generic controls, legends, camera helpers, and
reusable selection primitives.
- `src/features/workbench` owns conduits, junctions, orifices, outfalls, pumps,
SCADA sources, simulation overlays, and drainage-specific behavior.
- `src/features/agent` owns task reasoning UI and controlled recommendations.
Do not reintroduce supply-network layer IDs or assets into the drainage config.
GeoServer layer names, workspace defaults, map style tokens, titles, and Agent
copy must remain drainage-specific.
## Verification
Frontend changes should pass:
```bash
pnpm exec tsc --noEmit
```
Runs TypeScript validation without emitting files.
## Coding Style & Naming Conventions
- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components.
- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports.
- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`.
- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`.
- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`.
- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`.
- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`.
- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`.
- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`.
- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components.
- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions.
## Code Organization Style
Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file.
Recommended future pattern:
```txt
features/map/core/
components/
base-layers-control.tsx
scale-line.tsx
toolbar.tsx
zoom.tsx
legend.tsx
layer-control.tsx
hooks/
use-map-camera.ts
use-layer-visibility.ts
use-map-selection.ts
map/
style.ts
layer-utils.ts
features/workbench/
map-workbench-page.tsx
components/
workbench-top-bar.tsx
simulation-dock.tsx
simulation-result-panel.tsx
hooks/use-workbench-map.ts
map/sources.ts
map/layers.ts
map/annotation-layers.ts
state/types.ts
features/agent/
components/agent-assistant-panel.tsx
data/agent-task.ts
types.ts
```
Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions.
Keep the growth boundary clear:
- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives.
- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions.
- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`.
## Testing Guidelines
There is no formal test suite yet. For now, required checks are:
```bash
pnpm exec tsc --noEmit
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`.
Run focused Playwright tests when changing visible behavior, responsive layout,
runtime configuration, or map interactions. Name tests after behavior.
## Commit & Pull Request Guidelines
## Configuration and Security
This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as:
Browser configuration is injected through `/runtime-config.js` and validated in
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy
`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease
local migration.
```txt
Add direct GeoServer MVT sources
Refactor map workbench shell
```
Pull requests should include:
- Summary of user-visible changes.
- Notes on map/data source changes.
- Screenshots for UI changes.
- Verification commands run.
## Security & Configuration Tips
Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from:
```env
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
```
GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts.
Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
in untracked environment files.
+16
View File
@@ -0,0 +1,16 @@
: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"
@tts path /api/tts/edge
reverse_proxy @tts {$TTS_API_INTERNAL_BASE_URL:http://127.0.0.1:8790}
try_files {path} /index.html
file_server
}
+8 -8
View File
@@ -250,7 +250,7 @@ The task ticker is always centered against the full viewport, independent of Age
#### CSS Token Contract
`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
`src/styles.css` is the single source of truth for surface values. `src/features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
### Motion
@@ -360,7 +360,7 @@ The Agent panel is a persistent command instrument, so its outer shell should us
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
Collapsed Agent state is a floating 72px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
Required sections:
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
### Map Core Controls
Reusable controls under `features/map/core/components` should share one compact map-instrument language.
Reusable controls under `src/features/map/core/components` should share one compact map-instrument language.
#### Control Surface
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
Recommended implementation direction:
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`.
- Keep route files thin and move feature UI into `src/features/workbench`, `src/features/map/core`, and `src/features/agent`.
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
- Define operational colors as shared constants for both map layers and UI legends.
@@ -830,16 +830,16 @@ Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component s
Target component layering:
```txt
shared/ui
src/shared/ui
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
features/map/core/components
src/features/map/core/components
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
features/workbench/components
src/features/workbench/components
Water-network workbench components composed from shared UI and map core controls.
features/agent/components
src/features/agent/components
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
```
+22
View File
@@ -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/drainage-frontend-entrypoint
COPY --from=build /app/dist /srv
RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/drainage-frontend-entrypoint"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+1 -1
View File
@@ -8,7 +8,7 @@
## 颜色系统
运行时唯一来源是 `features/workbench/map/map-colors.ts``MAP_STYLE_TOKENS`。业务图层、模拟、工具、图例和测试不得建立平行颜色常量。
运行时唯一来源是 `src/features/workbench/map/map-colors.ts``MAP_STYLE_TOKENS`。业务图层、模拟、工具、图例和测试不得建立平行颜色常量。
| Token | HEX | OKLCH | 用途 |
| --- | --- | --- | --- |
+78 -9
View File
@@ -1,22 +1,91 @@
# Drainage Network Frontend
# Next TJWater Drainage Frontend
排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建
排水管网 WebGIS Agent 工作台。界面以地图为主视图,集成排水管渠、检查井、排放口、泵、SCADA 监测、异常工况、Agent 对话和受控前端动作
## 开发
项目已从 Next.js App Router 迁移到 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
## 主要功能
- 排水管网 MVT 图层和 SCADA 监测点展示
- 地图缩放、测量、绘制、图层控制、要素定位和视图导出
- 异常工况列表、详情分析和调度任务跟踪
- Agent 流式对话、推荐问题、会话管理和权限批准
- 经过 Schema 校验的 UI Envelope 和前端动作执行
- 运行时配置注入、MSW 本地模拟和开发面板
## 开发环境
```bash
corepack enable
pnpm install
cp .env.example .env.local
pnpm dev
```
默认访问地址为 <http://localhost:3000>
开发服务器默认地址为 <http://127.0.0.1:5173>。完整对话功能需要同时启动 Agent 后端
## 校验
## 运行时配置
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.env.local` 中设置:
| 变量 | 默认值 | 说明 |
| ------------------------------ | --------------------------------------------- | -------------------------------------- |
| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 |
| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 |
| `DRAINAGE_TTS_API_URL` | `/api/tts/edge` | 语音合成接口地址 |
| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址 |
| `TTS_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8790` | Vite/Caddy 代理访问的独立 TTS 服务地址 |
Vite 开发适配器会兼容现有 `.env.local` 中的 `NEXT_PUBLIC_*` 变量,生产容器应使用 `DRAINAGE_*` 变量。
排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。Agent 流式接口使用 `DRAINAGE_AGENT_API_BASE_URL`。语音合成依赖独立 TTS 服务;默认站内路径会由 Vite 或容器内 Caddy 转发到 `TTS_API_INTERNAL_BASE_URL`
## 常用命令
```bash
pnpm lint
pnpm exec tsc --noEmit
pnpm build
pnpm dev # 启动 Vite 开发服务器
pnpm build # 类型检查并生成生产构建
pnpm preview # 预览生产构建
pnpm typecheck # 运行 TypeScript 检查
pnpm lint # 运行 ESLint
pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试
```
地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。
## Docker
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`
```bash
docker build -t next-tjwater-drainage-frontend .
docker run --rm -p 8080:80 --env-file .env.local next-tjwater-drainage-frontend
```
启动后访问 <http://localhost:8080>。
## 项目结构
```text
src/
├── app/ 应用入口、Provider 和基础浏览器回归测试
├── features/
│ ├── agent/ Agent 协议、会话、动作执行和界面组件
│ ├── map/ 地图核心控件
│ └── workbench/ 排水业务、SCADA、调度面板和地图编排
├── mocks/ MSW handlers 和测试数据
├── shared/ 通用 UI、AI 元素、配置和工具
├── test/ Vitest 测试环境
└── styles.css Tailwind CSS 入口和全局样式
```
产品级浏览器回归仍位于 `tests/browser/`
## 构建说明
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认阈值。只要构建成功,这些提示不影响静态产物生成。
-13
View File
@@ -1,13 +0,0 @@
import { NextResponse } from "next/server";
import { GEOSERVER_WORKSPACE, MAP_URL } from "@/lib/config";
const TYPES = { junction: "geo_junctions", conduit: "geo_conduits", pipe: "geo_conduits", orifice: "geo_orifices", outfall: "geo_outfalls", pump: "geo_pumps" } as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null;
if (!body || !Array.isArray(body.ids) || body.ids.length < 1 || body.ids.length > 100 || !body.ids.every((id) => typeof id === "string" && id.trim())) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400 });
if (typeof body.featureType !== "string" || !(body.featureType in TYPES)) return NextResponse.json({ code: "UNSUPPORTED_FEATURE_TYPE" }, { status: 422 });
const ids = body.ids.map((id) => String(id).trim()); const cql = `id IN (${ids.map((id) => `'${id.replaceAll("'", "''")}'`).join(",")})`;
const typeName = `${GEOSERVER_WORKSPACE}:${TYPES[body.featureType as keyof typeof TYPES]}`;
const url = new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: typeName, outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString();
try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), cache: "no-store" }); const data = await response.json(); if (!response.ok || !data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) throw new Error(); return NextResponse.json({ type: "FeatureCollection", features: data.features.slice(0, 100) }); }
catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); }
}
-30
View File
@@ -1,30 +0,0 @@
import { NextResponse } from "next/server";
import {
MAX_CONDUIT_FEATURES,
MAX_MAP_FEATURE_IDS,
createMapFeatureWfsUrl,
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "@/features/workbench/map/map-feature-query";
export async function POST(request: Request) {
const parsed = parseMapFeatureQuery(await request.json().catch(() => null));
if (!parsed.ok) {
return NextResponse.json({ code: parsed.code }, { status: parsed.status });
}
try {
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
cache: "no-store",
signal: AbortSignal.timeout(8_000)
});
const collection = normalizeMapFeatureCollection(
await response.json(),
parsed.value.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES
);
if (!response.ok || !collection) throw new Error("Invalid WFS response");
return NextResponse.json(collection);
} catch {
return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 });
}
}
-59
View File
@@ -1,59 +0,0 @@
import { Communicate } from "edge-tts-ts";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
const MAX_TEXT_LENGTH = 12_000;
const jsonError = (message: string, status: number) =>
NextResponse.json({ error: message }, { status });
export async function POST(request: Request) {
let payload: { text?: unknown; voice?: unknown };
try {
payload = await request.json() as { text?: unknown; voice?: unknown };
} catch {
return jsonError("请求内容不是有效 JSON", 400);
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
if (!text) return jsonError("缺少待朗读文本", 400);
if (text.length > MAX_TEXT_LENGTH) return jsonError(`单次文本不能超过 ${MAX_TEXT_LENGTH}`, 413);
const voice = typeof payload.voice === "string" && payload.voice.trim()
? payload.voice.trim()
: DEFAULT_VOICE;
try {
const communicate = new Communicate(text, { voice });
const chunks: Uint8Array[] = [];
let byteLength = 0;
for await (const chunk of communicate.stream()) {
if (chunk.type !== "audio") continue;
chunks.push(chunk.data);
byteLength += chunk.data.byteLength;
}
if (!byteLength) return jsonError("语音服务返回了空音频", 502);
const audio = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
audio.set(chunk, offset);
offset += chunk.byteLength;
}
return new Response(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength), {
headers: {
"Content-Type": "audio/mpeg",
"Cache-Control": "no-store"
}
});
} catch (error) {
console.error("[Agent TTS] 语音生成失败", error);
return jsonError("语音生成失败", 502);
}
}
-58
View File
@@ -1,58 +0,0 @@
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const AGENT_BACKEND_BASE_URL =
process.env.AGENT_API_INTERNAL_BASE_URL ??
process.env.NEXT_PUBLIC_AGENT_API_BASE_URL ??
"http://127.0.0.1:8787";
type RouteContext = {
params: Promise<{ path?: string[] }>;
};
export async function GET(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function POST(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function PATCH(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function DELETE(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
async function proxyAgentRequest(request: NextRequest, context: RouteContext) {
const { path = [] } = await context.params;
const upstreamUrl = new URL(`/api/v1/agent/chat/${path.map(encodeURIComponent).join("/")}`, AGENT_BACKEND_BASE_URL);
upstreamUrl.search = request.nextUrl.search;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("content-length");
const body = request.method === "GET" || request.method === "HEAD" ? undefined : request.body;
const upstreamResponse = await fetch(upstreamUrl, {
method: request.method,
headers,
body,
cache: "no-store",
duplex: body ? "half" : undefined
} as RequestInit & { duplex?: "half" });
const responseHeaders = new Headers(upstreamResponse.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders
});
}
-30
View File
@@ -1,30 +0,0 @@
import type { Metadata, Viewport } from "next";
import { MapToaster } from "@/features/map/core/components/notice";
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
import "./globals.css";
export const metadata: Metadata = {
title: "排水管网",
description: "Agent 编排型沉浸式排水管网业务工作台"
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
{children}
<MapToaster />
</body>
</html>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { MapWorkbenchPage } from "@/features/workbench";
export default function Home() {
return <MapWorkbenchPage />;
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared",
"ui": "@/shared/ui",
"lib": "@/lib",
"utils": "@/lib/utils",
"hooks": "@/shared/hooks"
},
"iconLibrary": "lucide"
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
runtime_config=$(jq -cn \
--arg mapboxAccessToken "${DRAINAGE_MAPBOX_ACCESS_TOKEN:-}" \
--arg mapUrl "${DRAINAGE_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${DRAINAGE_GEOSERVER_WORKSPACE:-wenzhou}" \
--arg agentApiBaseUrl "${DRAINAGE_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg ttsApiUrl "${DRAINAGE_TTS_API_URL:-/api/tts/edge}" \
--arg enableDevPanel "${DRAINAGE_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${DRAINAGE_ENABLE_MSW:-false}" \
'{
DRAINAGE_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
DRAINAGE_MAP_URL: $mapUrl,
DRAINAGE_GEOSERVER_WORKSPACE: $geoserverWorkspace,
DRAINAGE_AGENT_API_BASE_URL: $agentApiBaseUrl,
DRAINAGE_TTS_API_URL: $ttsApiUrl,
DRAINAGE_ENABLE_DEV_PANEL: $enableDevPanel,
DRAINAGE_ENABLE_MSW: $enableMsw
}')
printf 'globalThis.__DRAINAGE_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js
exec "$@"
+81
View File
@@ -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 秒以上的浏览器进程内存趋势。
+36
View File
@@ -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: [".next/**", "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 }]
}
}
];
-41
View File
@@ -1,41 +0,0 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const nextConfigRequire = createRequire(require.resolve("eslint-config-next"));
const nextPlugin = nextConfigRequire("@next/eslint-plugin-next");
const reactHooksPlugin = nextConfigRequire("eslint-plugin-react-hooks");
const tsParser = nextConfigRequire("@typescript-eslint/parser");
export default [
{
ignores: [".next/**", "node_modules/**", "out/**"]
},
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: "latest",
sourceType: "module"
}
},
plugins: {
"@next/next": nextPlugin,
"react-hooks": reactHooksPlugin
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
...reactHooksPlugin.configs.recommended.rules
},
settings: {
next: {
rootDir: ["./"]
}
}
}
];
@@ -1,40 +0,0 @@
"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;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", 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"
fallbackClassName="h-12 w-12"
state={personaState}
statusLabel={statusLabel}
/>
<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>
);
}
@@ -1,66 +0,0 @@
"use client";
import { Bot } from "lucide-react";
import { useState } from "react";
import { Persona, type PersonaState, type PersonaVariant } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
type AgentPersonaProps = {
className?: string;
fallbackClassName?: string;
state?: PersonaState;
statusLabel?: string;
streaming?: boolean;
variant?: PersonaVariant;
};
export function AgentPersona({
className,
fallbackClassName,
state,
statusLabel,
streaming = false,
variant = "obsidian"
}: AgentPersonaProps) {
const [failed, setFailed] = useState(false);
const personaState = state ?? getAgentPersonaState(statusLabel, streaming);
if (failed) {
return (
<span
className={cn(
"grid shrink-0 place-items-center rounded-xl bg-violet-100 text-violet-700",
className,
fallbackClassName
)}
>
<Bot size={19} aria-hidden="true" />
</span>
);
}
return (
<Persona
className={cn(
"shrink-0",
variant === "obsidian" && "scale-110",
className
)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
/>
);
}
export function getAgentPersonaState(statusLabel = "", streaming = false): PersonaState {
if (/失败|不可用|错误|降级/.test(statusLabel)) {
return "asleep";
}
if (streaming || /请求|分析|生成|连接中|加载/.test(statusLabel)) {
return "thinking";
}
return "idle";
}
-354
View File
@@ -1,354 +0,0 @@
"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 "地图数据源请求失败,请检查网络或稍后重试。";
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Agent 编排型沉浸式排水管网业务工作台" />
<title>排水管网</title>
</head>
<body>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
const DEFAULT_MAP_URL = "https://geoserver.waternetwork.cn/geoserver";
const DEFAULT_GEOSERVER_WORKSPACE = "wenzhou";
export const MAP_URL = (
process.env.NEXT_PUBLIC_MAP_URL || DEFAULT_MAP_URL
).replace(/\/$/, "");
export const GEOSERVER_WORKSPACE = (
process.env.NEXT_PUBLIC_GEOSERVER_WORKSPACE || DEFAULT_GEOSERVER_WORKSPACE
).trim().replace(/^\/+|\/+$/g, "");
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-7
View File
@@ -1,7 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["edge-tts-ts"],
typedRoutes: false
};
export default nextConfig;
+45 -21
View File
@@ -1,21 +1,25 @@
{
"name": "drainage-network-frontend",
"name": "next-tjwater-drainage-frontend",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.25.0",
"type": "module",
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "vite",
"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",
"@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",
@@ -35,34 +39,54 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"edge-tts-ts": "^1.0.0",
"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",
"next": "15.5.19",
"react": "19.2.7",
"react-dom": "19.2.7",
"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",
"swr": "^2.4.2",
"tailwind-merge": "^3.6.0",
"use-stick-to-bottom": "^1.1.6"
"use-stick-to-bottom": "^1.1.6",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@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",
"autoprefixer": "^10.4.20",
"@vitejs/plugin-react-swc": "^4.0.2",
"eslint": "^9.17.0",
"eslint-config-next": "15.5.19",
"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",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.0",
"vite": "^7.0.0",
"vitest": "^4.1.9"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+21 -11
View File
@@ -1,19 +1,29 @@
import { defineConfig } from "playwright/test";
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
export default defineConfig({
testDir: "./tests/browser",
testMatch: "**/*.e2e.ts",
timeout: 30_000,
testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true,
reporter: "html",
use: {
baseURL,
viewport: { width: 1440, height: 900 }
trace: "on-first-retry"
},
webServer: {
command: "pnpm dev",
reuseExistingServer: true,
timeout: 120_000,
url: baseURL
}
command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL,
reuseExistingServer: false
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 }
}
}
]
});
+2794 -3330
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -1,7 +1,12 @@
packages:
- .
allowBuilds:
sharp: set this to true or false
unrs-resolver: set this to true or false
'@swc/core': true
esbuild: true
msw: true
onlyBuiltDependencies:
- sharp
- unrs-resolver
- "@swc/core"
- esbuild
- msw
-8
View File
@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
export default {
printWidth: 100,
tabWidth: 2,
semi: true,
trailingComma: "none"
};
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+1
View File
@@ -0,0 +1 @@
globalThis.__DRAINAGE_CONFIG__ = {};
-288
View File
@@ -1,288 +0,0 @@
"use client";
import type { RiveParameters } from "@rive-app/react-webgl2";
import {
useRive,
useStateMachineInput,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor
} from "@rive-app/react-webgl2";
import type { FC, ReactNode } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const useStrictModeSafeInit = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setReady(true));
return () => {
cancelAnimationFrame(id);
setReady(false);
};
}, []);
return ready;
};
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
const sources = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv"
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv"
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv"
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv"
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv"
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv"
}
};
export type PersonaVariant = keyof typeof sources;
export type PersonaProps = {
state?: PersonaState;
onLoad?: RiveParameters["onLoad"];
onLoadError?: RiveParameters["onLoadError"];
onReady?: () => void;
onPause?: RiveParameters["onPause"];
onPlay?: RiveParameters["onPlay"];
onStop?: RiveParameters["onStop"];
className?: string;
variant?: PersonaVariant;
};
const stateMachine = "default";
const getCurrentTheme = (): "light" | "dark" => {
if (typeof window !== "undefined") {
if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}
}
return "light";
};
const useTheme = (enabled: boolean) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
if (!enabled) {
return;
}
setTheme(getCurrentTheme());
const observer = new MutationObserver(() => {
setTheme(getCurrentTheme());
});
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true
});
let mql: MediaQueryList | null = null;
const handleMediaChange = () => {
setTheme(getCurrentTheme());
};
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)");
mql.addEventListener("change", handleMediaChange);
}
return () => {
observer.disconnect();
if (mql) {
mql.removeEventListener("change", handleMediaChange);
}
};
}, [enabled]);
return theme;
};
type PersonaSource = (typeof sources)[PersonaVariant];
type PersonaWithModelProps = {
rive: ReturnType<typeof useRive>["rive"];
source: PersonaSource;
children: ReactNode;
};
const PersonaWithModel = memo(({ rive, source, children }: PersonaWithModelProps) => {
const theme = useTheme(source.dynamicColor);
const viewModel = useViewModel(rive, { useDefault: true });
const viewModelInstance = useViewModelInstance(viewModel, {
rive,
useDefault: true
});
const viewModelInstanceColor = useViewModelInstanceColor(
"color",
viewModelInstance
);
useEffect(() => {
if (!(viewModelInstanceColor && source.dynamicColor)) {
return;
}
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
viewModelInstanceColor.setRgb(r, g, b);
}, [viewModelInstanceColor, theme, source.dynamicColor]);
return children;
});
PersonaWithModel.displayName = "PersonaWithModel";
const PersonaWithoutModel = memo(({ children }: { children: ReactNode }) => children);
PersonaWithoutModel.displayName = "PersonaWithoutModel";
export const Persona: FC<PersonaProps> = memo(
({
variant = "obsidian",
state = "idle",
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
className
}) => {
const source = sources[variant];
const callbacksRef = useRef({
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop
});
useEffect(() => {
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop
};
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
const stableCallbacks = useMemo(
() => ({
onLoad: ((loadedRive) =>
callbacksRef.current.onLoad?.(loadedRive)) as RiveParameters["onLoad"],
onLoadError: ((error) =>
callbacksRef.current.onLoadError?.(error)) as RiveParameters["onLoadError"],
onPause: ((event) =>
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
onPlay: ((event) =>
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
onReady: () => callbacksRef.current.onReady?.(),
onStop: ((event) =>
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"]
}),
[]
);
const ready = useStrictModeSafeInit();
const { rive, RiveComponent } = useRive(
ready
? {
autoplay: true,
onLoad: stableCallbacks.onLoad,
onLoadError: stableCallbacks.onLoadError,
onPause: stableCallbacks.onPause,
onPlay: stableCallbacks.onPlay,
onRiveReady: stableCallbacks.onReady,
onStop: stableCallbacks.onStop,
src: source.source,
stateMachines: stateMachine
}
: null
);
const listeningInput = useStateMachineInput(
rive,
stateMachine,
"listening"
);
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
useEffect(() => {
if (listeningInput) {
listeningInput.value = state === "listening";
}
if (thinkingInput) {
thinkingInput.value = state === "thinking";
}
if (speakingInput) {
speakingInput.value = state === "speaking";
}
if (asleepInput) {
asleepInput.value = state === "asleep";
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
const visual = <RiveComponent className={cn("size-16 shrink-0", className)} />;
if (!source.hasModel) {
return <PersonaWithoutModel>{visual}</PersonaWithoutModel>;
}
return (
<PersonaWithModel rive={rive} source={source}>
{visual}
</PersonaWithModel>
);
}
);
Persona.displayName = "Persona";
+120
View File
@@ -0,0 +1,120 @@
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.__DRAINAGE_CONFIG__);
expect(runtimeConfig).toMatchObject({
DRAINAGE_MAP_URL: expect.any(String),
DRAINAGE_GEOSERVER_WORKSPACE: expect.any(String),
DRAINAGE_AGENT_API_BASE_URL: expect.any(String),
DRAINAGE_TTS_API_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("/icons/scada-integrated-monitoring.png");
expect(mapAsset.ok()).toBe(true);
expect(mapAsset.headers()["content-type"]).toBe("image/png");
await expect(page.getByLabel("Agent 状态").first()).toBeVisible();
await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1);
});
test("animates the compact header menu without overflowing", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await page.locator('button[aria-label^="查看异常处置面板"]:visible').click();
const menu = page.locator('[role="menu"][data-state="open"]');
await expect(menu).toBeVisible();
const animation = await menu.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
duration: Number.parseFloat(style.animationDuration) * 1_000,
name: style.animationName
};
});
expect(animation.name).toContain("enter");
expect(animation.duration).toBeGreaterThan(0);
const menuBounds = await menu.boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds!.x).toBeGreaterThanOrEqual(0);
expect(menuBounds!.x + menuBounds!.width).toBeLessThanOrEqual(375);
});
test("renders map notices through a single toaster", async ({ page }) => {
await page.goto("/");
await expect(page.locator("[data-sonner-toaster]")).toHaveCount(1);
await page.getByLabel("打开用户菜单").click();
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
await expect(page.locator("[data-sonner-toast]")).toHaveCount(1);
});
test("preserves compact typography in header and agent controls", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first();
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
await expect(headerControl).toHaveCSS("font-weight", "600");
await expect(approvalControl).toHaveCSS("font-size", "12px");
await expect(approvalControl).toHaveCSS("line-height", "16px");
await expect(suggestion).toHaveCSS("font-size", "12px");
await expect(suggestion).toHaveCSS("font-weight", "500");
});
test("lets interactive utilities override surface materials", async ({ page }) => {
await page.goto("/");
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
const restingBackground = await suggestion.evaluate(
(element) => window.getComputedStyle(element).backgroundColor
);
await suggestion.hover();
const hoverBackground = await suggestion.evaluate(
(element) => window.getComputedStyle(element).backgroundColor
);
expect(hoverBackground).not.toBe(restingBackground);
});
test("preserves v3 menu and panel styling through Tailwind v4", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.getByLabel("打开用户菜单").click();
const menu = page.locator('[role="menu"][data-state="open"]');
const menuTransformOrigin = await menu.evaluate(
(element) => window.getComputedStyle(element).transformOrigin
);
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
);
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
expect(commandShadow).not.toContain("0px 1px 3px 0px");
});
+12
View File
@@ -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>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
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}
</SWRConfig>
);
}
@@ -1,3 +1,5 @@
import { env } from "@/shared/config/env";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject";
@@ -110,9 +112,7 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>;
};
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
@@ -0,0 +1,44 @@
"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;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({
personaState,
statusLabel = "Agent 已就绪",
onExpand
}: AgentCollapsedRailProps) {
const expandLabel = `展开排水助手面板,当前状态:${statusLabel}`;
return (
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
<button
type="button"
aria-label="展开排水助手面板"
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
</span>
</button>
</aside>
);
}
@@ -24,7 +24,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
@@ -105,7 +105,10 @@ type AgentCommandPanelProps = {
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => void;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyPermission?: (
permission: AgentPermissionRequest,
reply: AgentPermissionReply
) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
@@ -223,233 +226,245 @@ export function AgentCommandPanel({
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona
className="h-12 w-12"
fallbackClassName="h-12 w-12 rounded-xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className="h-12 w-12" state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
</p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
getAgentConnectionClassName(statusLabel)
)}
/>
{getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
</div>
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
{sessionTitle}
</p>
</div>
</div>
</div>
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
>
<History size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</header>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
<BackendMessageList
key="messages"
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
isSpeechSupported={isSpeechSupported}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={speak}
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
/>
) : (
<motion.div
key="empty"
className="flex flex-1 items-center justify-center px-4 py-8"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
</motion.div>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
onClick={submitPrompt}
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
>
<span className="truncate">{suggestion}</span>
</Suggestion>
))}
</Suggestions>
>
<History size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
</div>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-sm transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:!ring-0"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ApprovalModeSelect
value={approvalMode}
disabled={streaming}
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={isListening ? stopListening : () => startListening(showRecognitionError)}
/>
) : null}
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
</PromptInputSubmit>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</header>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
<BackendMessageList
key="messages"
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
isSpeechSupported={isSpeechSupported}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={speak}
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
/>
) : (
<motion.div
key="empty"
className="flex flex-1 items-center justify-center px-4 py-8"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
</motion.div>
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<Suggestions
aria-label="推荐问题"
role="group"
className="pointer-events-auto px-0.5"
>
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
onClick={submitPrompt}
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
>
<span className="truncate">{suggestion}</span>
</Suggestion>
))}
</Suggestions>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</Agent>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ApprovalModeSelect
value={approvalMode}
disabled={streaming}
onValueChange={onApprovalModeChange}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
) : null}
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</Agent>
</aside>
</MotionConfig>
);
@@ -486,10 +501,9 @@ function VoiceInputButton({
"border-transparent bg-transparent text-blue-600 shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening &&
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
)}
disabled={disabled}
onClick={onClick}
@@ -501,8 +515,12 @@ function VoiceInputButton({
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }}
transition={reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }}
animate={
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
}
transition={
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
}
/>
) : null}
@@ -524,14 +542,17 @@ function VoiceInputButton({
className="w-px rounded-full bg-red-500"
style={{ height: bar.height }}
animate={
reduceMotion
? { scaleY: 0.72 }
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: { duration: bar.duration, delay: bar.delay, ease: "easeInOut", repeat: Infinity }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
@@ -581,7 +602,11 @@ function ApprovalModeSelect({
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
{requestApproval ? <Shield size={14} aria-hidden="true" /> : <ShieldCheck size={14} aria-hidden="true" />}
{requestApproval ? (
<Shield size={14} aria-hidden="true" />
) : (
<ShieldCheck size={14} aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
</Button>
@@ -592,7 +617,9 @@ function ApprovalModeSelect({
sideOffset={8}
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500"></DropdownMenuLabel>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
@@ -605,7 +632,9 @@ function ApprovalModeSelect({
<span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span>
</span>
{requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" /> : null}
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
@@ -619,7 +648,9 @@ function ApprovalModeSelect({
<span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span>
</span>
{!requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" /> : null}
{!requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -661,7 +692,8 @@ function AgentConversationScrollManager({
!previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
(Boolean(snapshot.lastUserMessageId) &&
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
if (!forceScroll) {
return;
@@ -704,9 +736,7 @@ function AgentModelSelect({
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
</span>
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500"></span>
);
}
@@ -752,10 +782,16 @@ function AgentModelSelect({
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-slate-500">
{model.description}
</span>
</span>
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
{selected ? (
<CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
@@ -908,7 +944,10 @@ function BackendMessageList({
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyPermission?: (
permission: AgentPermissionRequest,
reply: AgentPermissionReply
) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
isSpeechSupported: boolean;
@@ -920,7 +959,11 @@ function BackendMessageList({
onStopSpeech: () => void;
}) {
return (
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
<motion.div
className="flex w-full flex-col gap-4"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => (
<motion.div
@@ -938,7 +981,8 @@ function BackendMessageList({
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
)}
>
<div className="space-y-3">
@@ -1040,7 +1084,12 @@ function AgentSpeechMessage({
const browserSelection = window.getSelection();
const container = contentRef.current;
if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) {
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
@@ -1108,49 +1157,57 @@ function AgentSpeechMessage({
{children}
</div>
{selectionAnchor && typeof document !== "undefined" ? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600" aria-hidden="true">
<Volume2 size={15} />
</span>
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
) : null}
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
y: 0,
scale: 0.95,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
}}
whileTap={{ scale: 0.96 }}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
{isSupported && !disabled ? (
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}>
<SpeechIconButton
label="语音播放"
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}
>
<Volume2 size={15} aria-hidden="true" />
</SpeechIconButton>
) : null}
@@ -1185,7 +1242,13 @@ function AgentSpeechMessage({
</>
) : null}
<span className="text-[11px] text-slate-400">
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
{speechState === "idle"
? "语音播放"
: speechState === "loading"
? "正在生成"
: speechState === "playing"
? "播放中"
: "已暂停"}
</span>
</div>
) : null}
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
return (
<div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
<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>
@@ -685,7 +685,7 @@ function QuestionInput({
) : 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"
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
@@ -0,0 +1,79 @@
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}
aria-label="Agent 状态"
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>
);
}
@@ -48,7 +48,7 @@ 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="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<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">
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { env } from "@/shared/config/env";
import {
splitSpeechTextIntoChunks,
type AgentSpeakOptions,
@@ -122,7 +123,7 @@ export function useAgentSpeechSynthesis() {
const controller = new AbortController();
fetchControllersRef.current.add(controller);
const promise = fetch("/api/tts/edge", {
const promise = fetch(env.DRAINAGE_TTS_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
@@ -5,7 +5,7 @@ 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(), "features/agent/components/streaming-token-response.tsx"),
join(process.cwd(), "src/features/agent/components/streaming-token-response.tsx"),
"utf8"
);
@@ -4,7 +4,7 @@ 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(), "app/globals.css"), "utf8");
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*\}/
@@ -114,7 +114,7 @@ export function BaseLayersControl({
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
@@ -166,7 +166,7 @@ export function BaseLayersControl({
"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",
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
@@ -186,7 +186,7 @@ export function BaseLayersControl({
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)}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs 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 ? (
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
)}
>
{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">
<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-xs">
<Check size={10} aria-hidden="true" />
</span>
) : null}
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
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"
? "bg-blue-600 text-white shadow-xs"
: "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"
@@ -64,7 +64,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
"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"
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs 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"
)}
@@ -1,5 +1,4 @@
import type { LucideIcon } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -61,7 +60,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
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 ? <Image src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
{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>
@@ -0,0 +1,60 @@
"use client";
import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
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"
};
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-hidden 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);
}
@@ -0,0 +1,154 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } 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_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export 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>
);
}
export function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
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 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;
}
@@ -0,0 +1,14 @@
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;
};
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notice-types";
export function MapToaster() {
return (
<Toaster
closeButton={false}
expand
visibleToasts={4}
position="top-center"
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 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 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,75 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
import { AgentTaskTicker } from "./agent-task-ticker";
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
};
}
function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: visibilityState
});
document.dispatchEvent(new Event("visibilitychange"));
}
function getActiveConditionId(container: HTMLElement) {
const activeCards = container.querySelectorAll<HTMLElement>('article[aria-hidden="false"]');
return activeCards.item(activeCards.length - 1).dataset.conditionId;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
setDocumentVisibility("visible");
});
describe("AgentTaskTicker page visibility", () => {
it("does not rotate or retain exiting cards while the page is hidden", () => {
vi.useFakeTimers();
setDocumentVisibility("visible");
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch"),
createRunningCondition("condition-c", "pump-energy"),
createRunningCondition("condition-d", "network-simulation")
];
const { container } = render(<AgentTaskTicker conditions={conditions} />);
const initialConditionId = getActiveConditionId(container);
act(() => {
setDocumentVisibility("hidden");
vi.advanceTimersByTime(18_000);
});
expect(getActiveConditionId(container)).toBe(initialConditionId);
expect(container.querySelectorAll("article")).toHaveLength(3);
act(() => {
setDocumentVisibility("visible");
});
act(() => {
vi.advanceTimersByTime(6_000);
});
expect(getActiveConditionId(container)).not.toBe(initialConditionId);
});
});
@@ -101,6 +101,10 @@ export function AgentTaskTicker({
active: false
}));
const [isHovered, setIsHovered] = useState(false);
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
typeof document === "undefined" || document.visibilityState === "visible"
));
const [visibilityRevision, setVisibilityRevision] = useState(0);
const runningConditions = conditions;
const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
useEffect(() => {
setNowMs(Date.now());
if (!isDocumentVisible) {
return;
}
const refreshNow = () => setNowMs(Date.now());
refreshNow();
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
refreshNow();
}, 1_000);
return () => window.clearInterval(intervalId);
}, []);
}, [isDocumentVisible]);
useEffect(() => {
setRotationIndex(0);
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = document.visibilityState === "visible";
releaseTaskSwitchLock();
finishTaskSwitchAnimation();
setIsHovered(false);
setIsDocumentVisible(isVisible);
setVisibilityRevision((current) => current + 1);
if (isVisible) {
setNowMs(Date.now());
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock]);
useEffect(() => {
const section = sectionRef.current;
if (!section) {
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
}, [handleWheel]);
useEffect(() => {
if (!hasMultipleRunningTasks || isHovered) {
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
return;
}
const intervalId = window.setInterval(() => {
if (document.visibilityState !== "visible") {
return;
}
shiftTask(1);
}, 6_000);
return () => window.clearInterval(intervalId);
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
if (visibleTickerCards.length === 0) {
return null;
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
onMouseLeave={() => setIsHovered(false)}
>
<div className="absolute inset-0">
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
<AnimatePresence
key={`ticker-presence-${visibilityRevision}`}
custom={transitionDirection}
mode="popLayout"
initial={false}
>
{visibleTickerCards
.slice()
.reverse()
@@ -320,6 +359,7 @@ function TickerTaskCard({
}}
exit="exit"
aria-hidden={!isActive}
data-condition-id={condition.id}
>
<div
className={cn(
@@ -1,9 +1,8 @@
"use client";
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/lib/utils";
import { SCADA_ICON_PATHS } from "../map/scada";
import type { DetailFeature } from "../types";
@@ -130,14 +129,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
{feature.layer === "scada" ? (
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
<div className="flex items-center gap-3">
<Image src={getScadaPresentation(feature.properties.kind).iconPath} alt="" width={42} height={42} />
<img
src={getScadaPresentation(feature.properties.kind).iconPath}
alt=""
width={42}
height={42}
/>
<div className="min-w-0">
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
</div>
</div>
{scadaQueryUuid ? (
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-sm">
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-xs">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-500"> ID</p>
<button
@@ -145,7 +149,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
onClick={() => void handleCopyScadaUuid()}
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
>
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
@@ -7,7 +7,7 @@ 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 { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/lib/utils";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
@@ -138,12 +138,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
<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">
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
<X size={16} aria-hidden="true" />
</button>
</div>

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