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. - 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. - 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. - 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 DRAINAGE_MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
NEXT_PUBLIC_ENABLE_DEV_PANEL=false 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 .DS_Store
.env.local node_modules
.next/ .next
node_modules/ dist
playwright-report/ coverage
test-results/ playwright-report
tsconfig.tsbuildinfo test-results
.env
.env.*
!.env.example
*.log
*.tsbuildinfo
*:Zone.Identifier
供水管网_Agent_WebGIS_设计与技术框架.md 供水管网_Agent_WebGIS_设计与技术框架.md
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md 现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
todo.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。 - `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`features/workbench/components/workbench-top-bar.tsx` - 文件:`src/features/workbench/components/workbench-top-bar.tsx`
### `acrylic-panel` ### `acrylic-panel`
@@ -42,15 +42,15 @@
- `AlertMenu``ScenarioMenu``UserMenu`,顶部导航下拉菜单。 - `AlertMenu``ScenarioMenu``UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。 - `MapNotice`,地图通知。
- 直接或间接涉及文件: - 直接或间接涉及文件:
- `features/agent/components/agent-command-panel.tsx` - `src/features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-collapsed-rail.tsx` - `src/features/agent/components/agent-collapsed-rail.tsx`
- `features/map/core/components/map-control-styles.ts` - `src/features/map/core/components/map-control-styles.ts`
- `features/map/core/components/notice.tsx` - `src/features/map/core/components/notice.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx` - `src/features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/feature-popover.tsx` - `src/features/workbench/components/feature-popover.tsx`
- `features/workbench/components/feature-insight-panel.tsx` - `src/features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/map-dev-panel.tsx` - `src/features/workbench/components/map-dev-panel.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx` - `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control` ### `acrylic-control`
@@ -64,7 +64,7 @@
- `BaseLayersControl`,底图切换入口与预览控件。 - `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。 - `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。 - 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`features/map/core/components/map-control-styles.ts` - 共享定义:`src/features/map/core/components/map-control-styles.ts`
### `glass-transient` ### `glass-transient`
@@ -72,7 +72,7 @@
- 模糊:沿用控件级模糊,16px 至 20px。 - 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件: - 当前组件:
- `AgentTaskTicker` 的活动任务卡片。 - `AgentTaskTicker` 的活动任务卡片。
- 文件:`features/workbench/components/agent-task-ticker.tsx` - 文件:`src/features/workbench/components/agent-task-ticker.tsx`
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control` - 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`
## 2. 内部实体表面 ## 2. 内部实体表面
@@ -82,7 +82,7 @@
### `surface-dock` ### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。 - 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。 - 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件: - 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。 - 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。 - 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
@@ -101,17 +101,17 @@
- Header 下拉菜单的内部控制区。 - Header 下拉菜单的内部控制区。
- 通用 `IconButton` - 通用 `IconButton`
- 主要文件: - 主要文件:
- `features/agent/components/agent-collapsed-rail.tsx` - `src/features/agent/components/agent-collapsed-rail.tsx`
- `features/agent/components/agent-command-panel.tsx` - `src/features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-operational-brief.tsx` - `src/features/agent/components/agent-operational-brief.tsx`
- `features/map/core/components/control-panel.tsx` - `src/features/map/core/components/control-panel.tsx`
- `features/map/core/components/layer-control.tsx` - `src/features/map/core/components/layer-control.tsx`
- `features/map/core/components/measure-panel.tsx` - `src/features/map/core/components/measure-panel.tsx`
- `features/workbench/components/agent-task-ticker.tsx` - `src/features/workbench/components/agent-task-ticker.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx` - `src/features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/scheduled-condition-detail-panel.tsx` - `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `features/workbench/components/feature-insight-panel.tsx` - `src/features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx` - `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well` ### `surface-well`
@@ -132,16 +132,16 @@
- 要素洞察、属性列表和底图名称标签。 - 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。 - Header 下拉菜单中的详情块与空状态。
- 主要文件: - 主要文件:
- `features/agent/components/agent-command-panel.tsx` - `src/features/agent/components/agent-command-panel.tsx`
- `features/agent/components/agent-message-details.tsx` - `src/features/agent/components/agent-message-details.tsx`
- `features/agent/components/agent-operational-brief.tsx` - `src/features/agent/components/agent-operational-brief.tsx`
- `features/agent/components/agent-ui-envelope-renderer.tsx` - `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `features/workbench/components/scheduled-condition-feed.tsx` - `src/features/workbench/components/scheduled-condition-feed.tsx`
- `features/workbench/components/scheduled-condition-detail-panel.tsx` - `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `features/workbench/components/feature-insight-panel.tsx` - `src/features/workbench/components/feature-insight-panel.tsx`
- `features/workbench/components/feature-property-list.tsx` - `src/features/workbench/components/feature-property-list.tsx`
- `features/workbench/components/workbench-top-bar-menus.tsx` - `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `features/map/core/components/base-layers-control.tsx` - `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面 ## 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 # 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. - `src/app/`: application shell and providers.
- `lib/`: shared utilities that are not owned by a feature. - `src/features/map/core/`: reusable map UI and generic map helpers. This layer
- `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. must not know drainage-network business layer IDs 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. - `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
- `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. adapters, scenarios, and workbench-specific interactions.
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features. - `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
- Root markdown files are product/design references and should not be treated as runtime code. 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`. Use `pnpm`.
```bash ```bash
pnpm dev pnpm dev
```
Starts the local development server at `http://localhost:3000`.
```bash
pnpm build 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 ```bash
pnpm exec tsc --noEmit pnpm lint
``` pnpm typecheck
Runs TypeScript validation without emitting files. pnpm test
## 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 build 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 Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
Add direct GeoServer MVT sources Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
Refactor map workbench shell in untracked environment files.
```
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.
+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 #### 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 ### 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. 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: Required sections:
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
### Map Core Controls ### 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 #### Control Surface
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
Recommended implementation direction: 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 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. - 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. - 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: Target component layering:
```txt ```txt
shared/ui src/shared/ui
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants. 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. 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. 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. 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 | 用途 | | 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 ```bash
corepack enable
pnpm install pnpm install
cp .env.example .env.local
pnpm dev 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 ```bash
pnpm lint pnpm dev # 启动 Vite 开发服务器
pnpm exec tsc --noEmit pnpm build # 类型检查并生成生产构建
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", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.25.0", "type": "module",
"packageManager": "pnpm@11.8.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "vite",
"build": "next build", "build": "tsc --noEmit && vite build",
"start": "next start", "preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test" "test:browser": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.9", "@ai-sdk/react": "^4.0.9",
"@deck.gl/core": "9.3.7", "@deck.gl/core": "^9.3.7",
"@deck.gl/layers": "9.3.7", "@deck.gl/layers": "^9.3.7",
"@deck.gl/mapbox": "9.3.7", "@deck.gl/mapbox": "^9.3.7",
"@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-dialog": "^1.1.17",
@@ -35,34 +39,54 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.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", "katex": "^0.17.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"motion": "^12.40.0", "motion": "^12.40.0",
"nanoid": "^5.1.14", "nanoid": "^5.1.14",
"next": "15.5.19", "react": "^19.2.7",
"react": "19.2.7", "react-dom": "^19.2.7",
"react-dom": "19.2.7",
"shiki": "^4.2.0", "shiki": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"swr": "2.4.2", "swr": "^2.4.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"use-stick-to-bottom": "^1.1.6" "use-stick-to-bottom": "^1.1.6",
"zod": "^3.25.76"
}, },
"devDependencies": { "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": "^19.0.2",
"@types/react-dom": "^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": "^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", "playwright": "^1.61.1",
"postcss": "^8.4.49", "prettier": "^3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0",
"typescript": "^5.7.2", "typescript": "^7.0.0",
"vite": "^7.0.0",
"vitest": "^4.1.9" "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({ export default defineConfig({
testDir: "./tests/browser", testDir: ".",
testMatch: "**/*.e2e.ts", testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
timeout: 30_000, fullyParallel: true,
reporter: "html",
use: { use: {
baseURL, baseURL,
viewport: { width: 1440, height: 900 } trace: "on-first-retry"
}, },
webServer: { webServer: {
command: "pnpm dev", command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
reuseExistingServer: true, url: baseURL,
timeout: 120_000, reuseExistingServer: false
url: baseURL },
} 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: allowBuilds:
sharp: set this to true or false '@swc/core': true
unrs-resolver: set this to true or false esbuild: true
msw: true
onlyBuiltDependencies: onlyBuiltDependencies:
- sharp - "@swc/core"
- unrs-resolver - 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 AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentPermissionReply = "once" | "always" | "reject";
@@ -110,9 +112,7 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>; abort: (sessionId: string) => Promise<void>;
}; };
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const CHAT_PATH = "/api/v1/agent/chat"; 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 { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom"; 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 { Agent, AgentContent } from "@/shared/ai-elements/agent";
import { import {
Conversation, Conversation,
@@ -105,7 +105,10 @@ type AgentCommandPanelProps = {
onApprovalModeChange?: (mode: AgentApprovalMode) => void; onApprovalModeChange?: (mode: AgentApprovalMode) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void; onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => 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; onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}; };
@@ -223,233 +226,245 @@ export function AgentCommandPanel({
collapsing ? "agent-panel-collapse" : "agent-panel-enter" collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)} )}
> >
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent"> <Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header"> <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-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"> <div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona <AgentPersona className="h-12 w-12" state={personaState} />
className="h-12 w-12" <div className="min-w-0 flex-1">
fallbackClassName="h-12 w-12 rounded-xl" <div className="min-w-0">
state={personaState} <div className="flex min-w-0 items-center gap-2">
statusLabel={statusLabel} <p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
streaming={streaming}
/> </p>
<div className="min-w-0 flex-1"> <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">
<div className="min-w-0"> <span
<div className="flex min-w-0 items-center gap-2"> className={cn(
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p> "h-1.5 w-1.5 rounded-full",
<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"> getAgentConnectionClassName(statusLabel)
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} /> )}
{getAgentConnectionLabel(statusLabel)} />
</span> {getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
</div> </div>
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
{sessionTitle}
</p>
</div> </div>
</div> </div>
</div> <button
<button type="button"
type="button" aria-label="新建 Agent 对话"
aria-label="新建 Agent 对话" title="新建 Agent 对话"
title="新建 Agent 对话" onClick={() => {
onClick={() => { setHistoryOpen(false);
setHistoryOpen(false); setPrompt("");
setPrompt(""); void Promise.resolve(onStartNewSession?.());
void Promise.resolve(onStartNewSession?.()); }}
}} className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
className="agent-panel-icon-button grid h-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" }}
> >
<AgentHistoryPanel <Plus size={17} aria-hidden="true" />
activeSessionId={activeSessionId} </button>
loadingSessionId={loadingSessionId} <button
loading={sessionHistoryLoading} type="button"
sessions={sessionHistory} aria-label="打开 Agent 历史记录"
onRefresh={onRefreshHistory} title="打开 Agent 历史记录"
onSelectSession={(nextSessionId) => { onClick={() => setHistoryOpen((open) => !open)}
setLoadingSessionId(nextSessionId); className={cn(
void Promise.resolve(onLoadHistorySession?.(nextSessionId)) "agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
.then(() => setHistoryOpen(false)) historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
.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> >
<History size={17} aria-hidden="true" />
<AgentUiEnvelopeRenderer results={uiResults} /> </button>
</ConversationContent> <button
<AgentConversationScrollManager type="button"
activeSessionId={activeSessionId} aria-label="折叠 Agent 面板"
messages={messages} title="折叠 Agent 面板"
scrollRequestId={scrollRequestId} onClick={onCollapse}
/> className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
<ConversationScrollButton className="bottom-3" /> >
</Conversation> <ChevronLeft size={17} aria-hidden="true" />
</AgentContent> </button>
<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> </div>
) : null} <AnimatePresence initial={false}>
<PromptInput {historyOpen ? (
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" <motion.div
onSubmit={(message) => { key="history"
const nextPrompt = message.text.trim(); initial="initial"
if (nextPrompt) { animate="animate"
submitPrompt(nextPrompt); exit="exit"
} variants={agentPanelSectionVariants}
}} style={{ overflow: "hidden" }}
>
<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" />} <AgentHistoryPanel
</PromptInputSubmit> 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> </div>
</PromptInputFooter> ) : null}
</PromptInput> <PromptInput
</div> 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!"
</Agent> 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> </aside>
</MotionConfig> </MotionConfig>
); );
@@ -486,10 +501,9 @@ function VoiceInputButton({
"border-transparent bg-transparent text-blue-600 shadow-none", "border-transparent bg-transparent text-blue-600 shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200", "transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"hover:bg-slate-50 hover:text-blue-700", "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", "disabled:cursor-not-allowed disabled:opacity-45",
isListening && isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
)} )}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
@@ -501,8 +515,12 @@ function VoiceInputButton({
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20" className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
aria-hidden="true" aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }} animate={
transition={reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }} 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} ) : null}
@@ -524,14 +542,17 @@ function VoiceInputButton({
className="w-px rounded-full bg-red-500" className="w-px rounded-full bg-red-500"
style={{ height: bar.height }} style={{ height: bar.height }}
animate={ animate={
reduceMotion reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
? { scaleY: 0.72 }
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
} }
transition={ transition={
reduceMotion reduceMotion
? { duration: 0.16 } ? { 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" aria-hidden="true"
/> />
@@ -581,7 +602,11 @@ function ApprovalModeSelect({
disabled={disabled || !onValueChange} disabled={disabled || !onValueChange}
aria-label="权限批准模式" 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> <span>{requestApproval ? "请求批准" : "始终允许"}</span>
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" /> <ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
</Button> </Button>
@@ -592,7 +617,9 @@ function ApprovalModeSelect({
sideOffset={8} sideOffset={8}
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg" 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 <DropdownMenuItem
className={cn( className={cn(
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50", "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-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span> <span className="block text-xs leading-5 text-slate-500"></span>
</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>
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
@@ -619,7 +648,9 @@ function ApprovalModeSelect({
<span className="block text-sm font-semibold text-slate-800"></span> <span className="block text-sm font-semibold text-slate-800"></span>
<span className="block text-xs leading-5 text-slate-500"></span> <span className="block text-xs leading-5 text-slate-500"></span>
</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> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -661,7 +692,8 @@ function AgentConversationScrollManager({
!previousSnapshot || !previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId || previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId || previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId); (Boolean(snapshot.lastUserMessageId) &&
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
if (!forceScroll) { if (!forceScroll) {
return; return;
@@ -704,9 +736,7 @@ function AgentModelSelect({
if (!models.length) { if (!models.length) {
return ( return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500"> <span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500"></span>
</span>
); );
} }
@@ -752,10 +782,16 @@ function AgentModelSelect({
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} /> <ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
</span> </span>
<span className="min-w-0 flex-1"> <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-sm font-semibold text-slate-800">
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span> {model.label}
</span>
<span className="block truncate text-xs leading-4 text-slate-500">
{model.description}
</span>
</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> </DropdownMenuItem>
); );
})} })}
@@ -908,7 +944,10 @@ function BackendMessageList({
messages: AgentChatMessage[]; messages: AgentChatMessage[];
streaming: boolean; streaming: boolean;
streamRenderState: AgentStreamRenderState; 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; onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
isSpeechSupported: boolean; isSpeechSupported: boolean;
@@ -920,7 +959,11 @@ function BackendMessageList({
onStopSpeech: () => void; onStopSpeech: () => void;
}) { }) {
return ( 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"> <AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => ( {messages.map((message, index) => (
<motion.div <motion.div
@@ -938,7 +981,8 @@ function BackendMessageList({
<MessageContent <MessageContent
className={cn( className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white", 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"> <div className="space-y-3">
@@ -1040,7 +1084,12 @@ function AgentSpeechMessage({
const browserSelection = window.getSelection(); const browserSelection = window.getSelection();
const container = contentRef.current; const container = contentRef.current;
if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) { if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null); setSelection(null);
return; return;
} }
@@ -1108,49 +1157,57 @@ function AgentSpeechMessage({
{children} {children}
</div> </div>
{selectionAnchor && typeof document !== "undefined" ? createPortal( {selectionAnchor && typeof document !== "undefined"
<div ? createPortal(
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full" <div
style={{ left: selectionAnchor.left, top: selectionAnchor.top }} className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
> style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
<AnimatePresence> >
{selection ? ( <AnimatePresence>
<motion.button {selection ? (
key="speech-selection-action" <motion.button
type="button" key="speech-selection-action"
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" type="button"
initial={{ opacity: 0, y: 8, scale: 0.95 }} 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"
animate={{ initial={{ opacity: 0, y: 8, scale: 0.95 }}
opacity: 1, animate={{
y: 0, opacity: 1,
scale: 1, y: 0,
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] } scale: 1,
}} transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
exit={{ }}
opacity: 0, exit={{
y: 0, opacity: 0,
scale: 0.95, y: 0,
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] } scale: 0.95,
}} transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
whileTap={{ scale: 0.96 }} }}
onMouseDown={(event) => event.preventDefault()} whileTap={{ scale: 0.96 }}
onClick={speakFromSelection} 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
</span> className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600"
aria-hidden="true"
</motion.button> >
) : null} <Volume2 size={15} />
</AnimatePresence> </span>
</div>,
document.body </motion.button>
) : null} ) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
{isSupported && !disabled ? ( {isSupported && !disabled ? (
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5"> <div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
{speechState === "idle" ? ( {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" /> <Volume2 size={15} aria-hidden="true" />
</SpeechIconButton> </SpeechIconButton>
) : null} ) : null}
@@ -1185,7 +1242,13 @@ function AgentSpeechMessage({
</> </>
) : null} ) : null}
<span className="text-[11px] text-slate-400"> <span className="text-[11px] text-slate-400">
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"} {speechState === "idle"
? "语音播放"
: speechState === "loading"
? "正在生成"
: speechState === "playing"
? "播放中"
: "已暂停"}
</span> </span>
</div> </div>
) : null} ) : null}
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
return ( return (
<div className="px-3 pb-3"> <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="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-slate-950"></p> <p className="text-sm font-semibold text-slate-950"></p>
@@ -685,7 +685,7 @@ function QuestionInput({
) : null} ) : null}
{showCustomInput ? ( {showCustomInput ? (
<textarea <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="输入回答" placeholder="输入回答"
value={value.custom} value={value.custom}
disabled={disabled} 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; const { envelope } = result;
return ( 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="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center 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"> <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"; "use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { env } from "@/shared/config/env";
import { import {
splitSpeechTextIntoChunks, splitSpeechTextIntoChunks,
type AgentSpeakOptions, type AgentSpeakOptions,
@@ -122,7 +123,7 @@ export function useAgentSpeechSynthesis() {
const controller = new AbortController(); const controller = new AbortController();
fetchControllersRef.current.add(controller); fetchControllersRef.current.add(controller);
const promise = fetch("/api/tts/edge", { const promise = fetch(env.DRAINAGE_TTS_API_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
describe("StreamingTokenResponse", () => { describe("StreamingTokenResponse", () => {
it("renders the backend-smoothed stream without a second timer buffer", () => { it("renders the backend-smoothed stream without a second timer buffer", () => {
const source = readFileSync( 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" "utf8"
); );
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
describe("Streamdown fullscreen table", () => { describe("Streamdown fullscreen table", () => {
it("disables inherited token animations only inside the fullscreen copy", () => { 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( expect(styles).toMatch(
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/ /\[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_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active 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", : "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50" 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", "flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_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" option.disabled && "cursor-not-allowed opacity-50"
)} )}
> >
@@ -186,7 +186,7 @@ export function BaseLayersControl({
type="button" type="button"
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${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)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
)} )}
> >
{active ? ( {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" /> <Check size={10} aria-hidden="true" />
</span> </span>
) : null} ) : null}
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
item.active item.active
? "bg-blue-600 text-white shadow-sm" ? "bg-blue-600 text-white shadow-xs"
: "hover:bg-blue-50 hover:text-blue-700", : "hover:bg-blue-50 hover:text-blue-700",
isDanger && "hover:bg-red-50 hover:text-red-700", isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600" item.disabled && "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]", "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, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible 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", : "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)} )}
@@ -1,5 +1,4 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
MAP_ICON_CELL_RADIUS_CLASS_NAME, 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)} 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"> <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>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span> <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 active: false
})); }));
const [isHovered, setIsHovered] = useState(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 runningConditions = conditions;
const visibleTickerCards = useMemo( const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs), () => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]); }, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
useEffect(() => { useEffect(() => {
setNowMs(Date.now()); if (!isDocumentVisible) {
return;
}
const refreshNow = () => setNowMs(Date.now());
refreshNow();
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setNowMs(Date.now()); refreshNow();
}, 1_000); }, 1_000);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, []); }, [isDocumentVisible]);
useEffect(() => { useEffect(() => {
setRotationIndex(0); setRotationIndex(0);
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]); 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(() => { useEffect(() => {
const section = sectionRef.current; const section = sectionRef.current;
if (!section) { if (!section) {
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
}, [handleWheel]); }, [handleWheel]);
useEffect(() => { useEffect(() => {
if (!hasMultipleRunningTasks || isHovered) { if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
return; return;
} }
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
if (document.visibilityState !== "visible") {
return;
}
shiftTask(1); shiftTask(1);
}, 6_000); }, 6_000);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]); }, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
if (visibleTickerCards.length === 0) { if (visibleTickerCards.length === 0) {
return null; return null;
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}> <AnimatePresence
key={`ticker-presence-${visibilityRevision}`}
custom={transitionDirection}
mode="popLayout"
initial={false}
>
{visibleTickerCards {visibleTickerCards
.slice() .slice()
.reverse() .reverse()
@@ -320,6 +359,7 @@ function TickerTaskCard({
}} }}
exit="exit" exit="exit"
aria-hidden={!isActive} aria-hidden={!isActive}
data-condition-id={condition.id}
> >
<div <div
className={cn( className={cn(
@@ -1,9 +1,8 @@
"use client"; "use client";
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react"; import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react"; 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 { cn } from "@/lib/utils";
import { SCADA_ICON_PATHS } from "../map/scada"; import { SCADA_ICON_PATHS } from "../map/scada";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
@@ -130,14 +129,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
{feature.layer === "scada" ? ( {feature.layer === "scada" ? (
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}> <section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
<div className="flex items-center gap-3"> <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"> <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={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> <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>
</div> </div>
{scadaQueryUuid ? ( {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"> <div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-500"> ID</p> <p className="text-xs font-semibold text-slate-500"> ID</p>
<button <button
@@ -145,7 +149,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"} aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"} title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
onClick={() => void handleCopyScadaUuid()} 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")} /> <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")} /> <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_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles"; } 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 { cn } from "@/lib/utils";
import type { WaterNetworkSourceId } from "../map/sources"; import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
@@ -138,12 +138,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{canCopy ? ( {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")} /> <Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} /> <Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button> </button>
) : null} ) : 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" /> <X size={16} aria-hidden="true" />
</button> </button>
</div> </div>

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