diff --git a/.agents/skills/drainage-network-design/SKILL.md b/.agents/skills/drainage-network-design/SKILL.md index 7fdf434..9d9eb36 100644 --- a/.agents/skills/drainage-network-design/SKILL.md +++ b/.agents/skills/drainage-network-design/SKILL.md @@ -34,4 +34,4 @@ Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy correc - Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning. - Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels. - Preserve list identity and scroll position when a collapsed list expands into a detail workspace. -- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected. +- Validate frontend UI changes with `pnpm lint` and `pnpm typecheck`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..526a792 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +coverage +playwright-report +test-results +.env +.env.* +*.log diff --git a/.env.example b/.env.example index 38583ba..6f2aa1e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ -NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here -NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 -NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver -NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou -NEXT_PUBLIC_ENABLE_DEV_PANEL=false +DRAINAGE_MAPBOX_ACCESS_TOKEN= +DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver +DRAINAGE_GEOSERVER_WORKSPACE=wenzhou +DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 +DRAINAGE_TTS_API_URL=/api/tts/edge +DRAINAGE_ENABLE_DEV_PANEL=false +DRAINAGE_ENABLE_MSW=false + +# Optional development proxy target. This value is not exposed to browser code. +AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787 +TTS_API_INTERNAL_BASE_URL=http://127.0.0.1:8790 diff --git a/.gitignore b/.gitignore index 290481f..4664e45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ .DS_Store -.env.local -.next/ -node_modules/ -playwright-report/ -test-results/ -tsconfig.tsbuildinfo +node_modules +.next +dist +coverage +playwright-report +test-results +.env +.env.* +!.env.example +*.log +*.tsbuildinfo +*:Zone.Identifier 供水管网_Agent_WebGIS_设计与技术框架.md 现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md todo.md diff --git a/ACRYLIC_DESIGN_INVENTORY.md b/ACRYLIC_DESIGN_INVENTORY.md index 56b689d..b5ad775 100644 --- a/ACRYLIC_DESIGN_INVENTORY.md +++ b/ACRYLIC_DESIGN_INVENTORY.md @@ -1,6 +1,6 @@ # 亚克力设计类别与组件使用清单 -本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `app/globals.css` 为准,组件映射以 `features/` 下的现有代码为准。 +本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。 ## 总览 @@ -25,7 +25,7 @@ - 特征:保留底部分隔和下投影,不使用顶部高光。 - 当前组件: - `WorkbenchTopBar`,工作台顶部 Header。 -- 文件:`features/workbench/components/workbench-top-bar.tsx`。 +- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。 ### `acrylic-panel` @@ -42,15 +42,15 @@ - `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。 - `MapNotice`,地图通知。 - 直接或间接涉及文件: - - `features/agent/components/agent-command-panel.tsx` - - `features/agent/components/agent-collapsed-rail.tsx` - - `features/map/core/components/map-control-styles.ts` - - `features/map/core/components/notice.tsx` - - `features/workbench/components/scheduled-condition-feed.tsx` - - `features/workbench/components/feature-popover.tsx` - - `features/workbench/components/feature-insight-panel.tsx` - - `features/workbench/components/map-dev-panel.tsx` - - `features/workbench/components/workbench-top-bar-menus.tsx` + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-collapsed-rail.tsx` + - `src/features/map/core/components/map-control-styles.ts` + - `src/features/map/core/components/notice.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/feature-popover.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/map-dev-panel.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` ### `acrylic-control` @@ -64,7 +64,7 @@ - `BaseLayersControl`,底图切换入口与预览控件。 - `map-workbench-page.tsx` 中的浮动地图控制入口。 - 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。 -- 共享定义:`features/map/core/components/map-control-styles.ts`。 +- 共享定义:`src/features/map/core/components/map-control-styles.ts`。 ### `glass-transient` @@ -72,7 +72,7 @@ - 模糊:沿用控件级模糊,16px 至 20px。 - 当前组件: - `AgentTaskTicker` 的活动任务卡片。 -- 文件:`features/workbench/components/agent-task-ticker.tsx`。 +- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。 - 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。 ## 2. 内部实体表面 @@ -82,7 +82,7 @@ ### `surface-dock` - 用途:预留给固定 Dock 或侧边停靠区域。 -- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。 +- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。 - 适用组件: - 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。 - 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。 @@ -101,17 +101,17 @@ - Header 下拉菜单的内部控制区。 - 通用 `IconButton`。 - 主要文件: - - `features/agent/components/agent-collapsed-rail.tsx` - - `features/agent/components/agent-command-panel.tsx` - - `features/agent/components/agent-operational-brief.tsx` - - `features/map/core/components/control-panel.tsx` - - `features/map/core/components/layer-control.tsx` - - `features/map/core/components/measure-panel.tsx` - - `features/workbench/components/agent-task-ticker.tsx` - - `features/workbench/components/scheduled-condition-feed.tsx` - - `features/workbench/components/scheduled-condition-detail-panel.tsx` - - `features/workbench/components/feature-insight-panel.tsx` - - `features/workbench/components/workbench-top-bar-menus.tsx` + - `src/features/agent/components/agent-collapsed-rail.tsx` + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-operational-brief.tsx` + - `src/features/map/core/components/control-panel.tsx` + - `src/features/map/core/components/layer-control.tsx` + - `src/features/map/core/components/measure-panel.tsx` + - `src/features/workbench/components/agent-task-ticker.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/scheduled-condition-detail-panel.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` ### `surface-well` @@ -132,16 +132,16 @@ - 要素洞察、属性列表和底图名称标签。 - Header 下拉菜单中的详情块与空状态。 - 主要文件: - - `features/agent/components/agent-command-panel.tsx` - - `features/agent/components/agent-message-details.tsx` - - `features/agent/components/agent-operational-brief.tsx` - - `features/agent/components/agent-ui-envelope-renderer.tsx` - - `features/workbench/components/scheduled-condition-feed.tsx` - - `features/workbench/components/scheduled-condition-detail-panel.tsx` - - `features/workbench/components/feature-insight-panel.tsx` - - `features/workbench/components/feature-property-list.tsx` - - `features/workbench/components/workbench-top-bar-menus.tsx` - - `features/map/core/components/base-layers-control.tsx` + - `src/features/agent/components/agent-command-panel.tsx` + - `src/features/agent/components/agent-message-details.tsx` + - `src/features/agent/components/agent-operational-brief.tsx` + - `src/features/agent/components/agent-ui-envelope-renderer.tsx` + - `src/features/workbench/components/scheduled-condition-feed.tsx` + - `src/features/workbench/components/scheduled-condition-detail-panel.tsx` + - `src/features/workbench/components/feature-insight-panel.tsx` + - `src/features/workbench/components/feature-property-list.tsx` + - `src/features/workbench/components/workbench-top-bar-menus.tsx` + - `src/features/map/core/components/base-layers-control.tsx` ## 3. 语义状态表面 @@ -192,7 +192,7 @@ ## 共享类映射 -地图核心组件通过 `features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。 +地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。 | 共享常量 | 实际材质类 | 典型用途 | |---|---|---| diff --git a/AGENTS.md b/AGENTS.md index 7d86676..48e9967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,130 +1,90 @@ # Repository Guidelines -## Project Structure & Module Organization +## Project Structure -This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS. +This repository is a Vite React single-page business system for an Agent-driven +drainage-network WebGIS. Runtime TypeScript lives under `src/`. -- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin. -- `lib/`: shared utilities that are not owned by a feature. -- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows. -- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here. -- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly. -- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features. -- Root markdown files are product/design references and should not be treated as runtime code. +- `src/app/`: application shell and providers. +- `src/features/map/core/`: reusable map UI and generic map helpers. This layer + must not know drainage-network business layer IDs or Agent workflows. +- `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature + adapters, scenarios, and workbench-specific interactions. +- `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and + recommendations. +- `src/shared/`: reusable UI, AI elements, runtime configuration, and utilities. +- `src/mocks/`: MSW handlers and fixtures. +- `tests/browser/`: product-specific Playwright regressions. +- `public/`: static drainage and SCADA assets plus the runtime-config placeholder. -## Build, Test, and Development Commands +Do not add Next.js APIs or route handlers. Backend and BFF behavior belongs in +the Agent service or another service. Browser-safe GeoServer reads may happen +directly when CORS is enabled. + +## Commands Use `pnpm`. ```bash pnpm dev -``` -Starts the local development server at `http://localhost:3000`. - -```bash pnpm build +pnpm typecheck +pnpm lint +pnpm test +pnpm test:browser ``` -Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes. + +The Vite development server listens on `http://127.0.0.1:5173` by default. +`pnpm build` runs TypeScript validation before producing `dist/`. + +## Coding Style + +- Use TypeScript and React function components with explicit exported types at + module boundaries. +- Use two-space indentation and kebab-case filenames. +- Use PascalCase for components and types, camelCase for functions and values, + and `useXxx` for hooks. +- Keep orchestration components thin. Move sources, layers, event handling, and + feature adapters into focused modules. +- Keep MapLibre sources, layers, camera helpers, and interactions outside + presentational components. +- Keep Agent rendering schema-driven. Validate Agent output before it reaches + React, and represent map actions as typed data that can be previewed, + confirmed, applied, and rolled back. + +## Domain Boundaries + +- `src/features/map/core` owns generic controls, legends, camera helpers, and + reusable selection primitives. +- `src/features/workbench` owns conduits, junctions, orifices, outfalls, pumps, + SCADA sources, simulation overlays, and drainage-specific behavior. +- `src/features/agent` owns task reasoning UI and controlled recommendations. + +Do not reintroduce supply-network layer IDs or assets into the drainage config. +GeoServer layer names, workspace defaults, map style tokens, titles, and Agent +copy must remain drainage-specific. + +## Verification + +Frontend changes should pass: ```bash -pnpm exec tsc --noEmit -``` -Runs TypeScript validation without emitting files. - -## Coding Style & Naming Conventions - -- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components. -- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports. -- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`. -- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`. -- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`. -- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`. -- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`. -- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`. -- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`. -- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components. -- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions. - -## Code Organization Style - -Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file. - -Recommended future pattern: - -```txt -features/map/core/ - components/ - base-layers-control.tsx - scale-line.tsx - toolbar.tsx - zoom.tsx - legend.tsx - layer-control.tsx - hooks/ - use-map-camera.ts - use-layer-visibility.ts - use-map-selection.ts - map/ - style.ts - layer-utils.ts - -features/workbench/ - map-workbench-page.tsx - components/ - workbench-top-bar.tsx - simulation-dock.tsx - simulation-result-panel.tsx - hooks/use-workbench-map.ts - map/sources.ts - map/layers.ts - map/annotation-layers.ts - state/types.ts - -features/agent/ - components/agent-assistant-panel.tsx - data/agent-task.ts - types.ts -``` - -Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions. - -Keep the growth boundary clear: -- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives. -- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions. -- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`. - -## Testing Guidelines - -There is no formal test suite yet. For now, required checks are: - -```bash -pnpm exec tsc --noEmit +pnpm lint +pnpm typecheck +pnpm test pnpm build ``` -When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`. +Run focused Playwright tests when changing visible behavior, responsive layout, +runtime configuration, or map interactions. Name tests after behavior. -## Commit & Pull Request Guidelines +## Configuration and Security -This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as: +Browser configuration is injected through `/runtime-config.js` and validated in +`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy +`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease +local migration. -```txt -Add direct GeoServer MVT sources -Refactor map workbench shell -``` - -Pull requests should include: -- Summary of user-visible changes. -- Notes on map/data source changes. -- Screenshots for UI changes. -- Verification commands run. - -## Security & Configuration Tips - -Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from: - -```env -NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= -``` - -GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts. +Do not commit secrets, `.env.local`, generated `dist/`, dependency folders, +Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets +in untracked environment files. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..394f5dc --- /dev/null +++ b/Caddyfile @@ -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 +} diff --git a/DESIGN.md b/DESIGN.md index b7c2136..1d7e00d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -250,7 +250,7 @@ The task ticker is always centered against the full viewport, independent of Age #### CSS Token Contract -`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding. +`src/styles.css` is the single source of truth for surface values. `src/features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding. ### Motion @@ -360,7 +360,7 @@ The Agent panel is a persistent command instrument, so its outer shell should us Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer. -Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers. +Collapsed Agent state is a floating 72px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers. Required sections: @@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu ### Map Core Controls -Reusable controls under `features/map/core/components` should share one compact map-instrument language. +Reusable controls under `src/features/map/core/components` should share one compact map-instrument language. #### Control Surface @@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren Recommended implementation direction: -- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`. +- Keep route files thin and move feature UI into `src/features/workbench`, `src/features/map/core`, and `src/features/agent`. - Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values. - Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components. - Define operational colors as shared constants for both map layers and UI legends. @@ -830,16 +830,16 @@ Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component s Target component layering: ```txt -shared/ui +src/shared/ui Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants. -features/map/core/components +src/features/map/core/components Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend. -features/workbench/components +src/features/workbench/components Water-network workbench components composed from shared UI and map core controls. -features/agent/components +src/features/agent/components Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI. ``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2e6b32 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/MAP_STYLE_GUIDE.md b/MAP_STYLE_GUIDE.md index e52ce09..e4275cf 100644 --- a/MAP_STYLE_GUIDE.md +++ b/MAP_STYLE_GUIDE.md @@ -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 | 用途 | | --- | --- | --- | --- | diff --git a/README.md b/README.md index b374b78..fedc243 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,91 @@ -# Drainage Network Frontend +# Next TJWater Drainage Frontend -排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建。 +排水管网 WebGIS Agent 工作台。界面以地图为主视图,集成排水管渠、检查井、排放口、泵、SCADA 监测、异常工况、Agent 对话和受控前端动作。 -## 开发 +项目已从 Next.js App Router 迁移到 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。 + +## 主要功能 + +- 排水管网 MVT 图层和 SCADA 监测点展示 +- 地图缩放、测量、绘制、图层控制、要素定位和视图导出 +- 异常工况列表、详情分析和调度任务跟踪 +- Agent 流式对话、推荐问题、会话管理和权限批准 +- 经过 Schema 校验的 UI Envelope 和前端动作执行 +- 运行时配置注入、MSW 本地模拟和开发面板 + +## 开发环境 ```bash +corepack enable pnpm install +cp .env.example .env.local pnpm dev ``` -默认访问地址为 。 +开发服务器默认地址为 。完整对话功能需要同时启动 Agent 后端。 -## 校验 +## 运行时配置 + +浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.env.local` 中设置: + +| 变量 | 默认值 | 说明 | +| ------------------------------ | --------------------------------------------- | -------------------------------------- | +| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | +| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | +| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 | +| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 | +| `DRAINAGE_TTS_API_URL` | `/api/tts/edge` | 语音合成接口地址 | +| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | +| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | +| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址 | +| `TTS_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8790` | Vite/Caddy 代理访问的独立 TTS 服务地址 | + +Vite 开发适配器会兼容现有 `.env.local` 中的 `NEXT_PUBLIC_*` 变量,生产容器应使用 `DRAINAGE_*` 变量。 + +排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。Agent 流式接口使用 `DRAINAGE_AGENT_API_BASE_URL`。语音合成依赖独立 TTS 服务;默认站内路径会由 Vite 或容器内 Caddy 转发到 `TTS_API_INTERNAL_BASE_URL`。 + +## 常用命令 ```bash -pnpm lint -pnpm exec tsc --noEmit -pnpm build +pnpm dev # 启动 Vite 开发服务器 +pnpm build # 类型检查并生成生产构建 +pnpm preview # 预览生产构建 +pnpm typecheck # 运行 TypeScript 检查 +pnpm lint # 运行 ESLint +pnpm format # 使用 Prettier 格式化文件 +pnpm test # 运行 Vitest 单元测试 +pnpm test:watch # 监听模式运行单元测试 +pnpm test:browser # 运行 Playwright 浏览器测试 ``` -地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。 +## Docker + +生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`。 + +```bash +docker build -t next-tjwater-drainage-frontend . +docker run --rm -p 8080:80 --env-file .env.local next-tjwater-drainage-frontend +``` + +启动后访问 。 + +## 项目结构 + +```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 默认阈值。只要构建成功,这些提示不影响静态产物生成。 diff --git a/app/api/agent-locate/route.ts b/app/api/agent-locate/route.ts deleted file mode 100644 index 3de56ec..0000000 --- a/app/api/agent-locate/route.ts +++ /dev/null @@ -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 }); } -} diff --git a/app/api/map-features/route.ts b/app/api/map-features/route.ts deleted file mode 100644 index ee7d261..0000000 --- a/app/api/map-features/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/tts/edge/route.ts b/app/api/tts/edge/route.ts deleted file mode 100644 index 2ae6d72..0000000 --- a/app/api/tts/edge/route.ts +++ /dev/null @@ -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); - } -} diff --git a/app/api/v1/agent/chat/[...path]/route.ts b/app/api/v1/agent/chat/[...path]/route.ts deleted file mode 100644 index f25cedb..0000000 --- a/app/api/v1/agent/chat/[...path]/route.ts +++ /dev/null @@ -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 - }); -} diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index 74495db..0000000 --- a/app/layout.tsx +++ /dev/null @@ -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 ( - - - {children} - - - - ); -} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 2339c07..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapWorkbenchPage } from "@/features/workbench"; - -export default function Home() { - return ; -} diff --git a/components.json b/components.json deleted file mode 100644 index 6fa8bbb..0000000 --- a/components.json +++ /dev/null @@ -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" -} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..0946ee7 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/docs/rive-persona-incident.md b/docs/rive-persona-incident.md new file mode 100644 index 0000000..0930f29 --- /dev/null +++ b/docs/rive-persona-incident.md @@ -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 秒以上的浏览器进程内存趋势。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..329d987 --- /dev/null +++ b/eslint.config.js @@ -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 }] + } + } +]; diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index d0986a8..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -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: ["./"] - } - } - } -]; diff --git a/features/agent/components/agent-collapsed-rail.tsx b/features/agent/components/agent-collapsed-rail.tsx deleted file mode 100644 index a0009cc..0000000 --- a/features/agent/components/agent-collapsed-rail.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/features/agent/components/agent-persona.tsx b/features/agent/components/agent-persona.tsx deleted file mode 100644 index 1d48080..0000000 --- a/features/agent/components/agent-persona.tsx +++ /dev/null @@ -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 ( - - - ); - } - - return ( - 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"; -} diff --git a/features/map/core/components/notice.tsx b/features/map/core/components/notice.tsx deleted file mode 100644 index 75490b6..0000000 --- a/features/map/core/components/notice.tsx +++ /dev/null @@ -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["position"]>; - -const DEFAULT_POSITION: MapNoticePosition = "top-center"; - -const positionClassNames: Record = { - "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 = { - "top-center": "top-center", - "top-right": "top-right", - "bottom-left": "bottom-left", - "bottom-right": "bottom-right" -}; - -const toneClassNames: Record = { - 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 = { - info: "text-blue-600", - success: "text-green-600", - warning: "text-orange-500", - error: "text-red-500", - loading: "text-blue-600" -}; - -const noticeAccentClassNames: Record = { - info: "bg-blue-600", - success: "bg-green-500", - warning: "bg-orange-500", - error: "bg-red-500", - loading: "bg-blue-600" -}; - -const noticeIconSurfaceClassNames: Record = { - 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 ( - - ); -} - -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( - () => , - { - id: options.id, - duration, - position: sonnerPositions[position], - dismissible, - cancel: dismissible - ? { - label: , - 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 ( - - - : null} - /> - - ); -} - -function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) { - return ( -
-
- ); -} - -function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType }) { - return ( - - - ); -} - -function MapNoticeBody({ - title, - message, - action -}: { - title?: string; - message: string; - action?: ReactNode; -}) { - return ( - - {title ? {title} : null} - {message} - {action} - - ); -} - -function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - ); -} - -function MapNoticeCloseIcon() { - return ( - <> - 关闭通知 -