feat: initialize drainage network frontend
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: drainage-network-design
|
||||
description: Use when designing, redesigning, or adjusting visual styles, layout, motion, spacing, typography, map panels, workbench UI, Agent UI, tool panels, timeline/list/detail panels, or any component styling in drainage-network. Also use when a user makes substantial component UI changes that should be captured back into DESIGN.md.
|
||||
---
|
||||
|
||||
# Drainage Network Design
|
||||
|
||||
This skill keeps UI work aligned with the project design guide.
|
||||
|
||||
## Required Workflow
|
||||
|
||||
1. Before changing component layout, styling, motion, density, copy hierarchy, or interaction shape, read `DESIGN.md` from the `drainage-network` repo root.
|
||||
2. Apply the guide as the source of truth for map-first layout, command instruments, surface opacity, radius, typography, motion, and panel behavior.
|
||||
3. Prefer existing local style helpers and component patterns over new one-off visual systems.
|
||||
4. After implementing a substantial UI adjustment, update `DESIGN.md` when the change establishes a reusable rule, corrects an outdated rule, or resolves a repeated design tradeoff.
|
||||
5. Keep `DESIGN.md` updates behavior-level and reusable. Do not document one-off class names unless they represent an intentional design token or pattern.
|
||||
|
||||
## What Counts As Substantial
|
||||
|
||||
Update `DESIGN.md` when the work changes any of these:
|
||||
|
||||
- A reusable component pattern, such as collapsed-to-expanded detail panels, timeline rows, persistent map instruments, or Agent decision surfaces.
|
||||
- Visual hierarchy rules for shells, readable surfaces, rows, detail cards, or action areas.
|
||||
- Motion rules, including when content should enter, whether lists should remount, or how scroll position is preserved.
|
||||
- Information architecture, such as what appears in collapsed versus expanded states.
|
||||
- Common responsive behavior for map overlays or side panels.
|
||||
|
||||
Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy corrections, or purely local implementation cleanup unless the guide is already contradicted by the current UI.
|
||||
|
||||
## Implementation Defaults
|
||||
|
||||
- Keep the map as the primary canvas. Floating UI should behave like operational instruments, not generic dashboard cards.
|
||||
- Use `surface-map-control` treatment for persistent map instruments and readable inner surfaces for dense text.
|
||||
- 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Next WebGIS Design"
|
||||
short_description: "Align component UI work with DESIGN.md."
|
||||
default_prompt: "Use $drainage-network-design to update this component while following the project design guide."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
|
||||
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
.env.local
|
||||
.next/
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
供水管网_Agent_WebGIS_设计与技术框架.md
|
||||
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
||||
todo.md
|
||||
@@ -0,0 +1,130 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS.
|
||||
|
||||
- `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.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use `pnpm`.
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Starts the local development server at `http://localhost:3000`.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes.
|
||||
|
||||
```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 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`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as:
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,823 @@
|
||||
# Immersive Drainage Network WebGIS Design
|
||||
|
||||
## Overview
|
||||
|
||||
This product is an immersive command workspace for drainage network dispatch, simulation, and Agent-assisted decision making. The interface should feel like entering a live operational map, not browsing a dashboard or a marketing page.
|
||||
|
||||
The map is the primary canvas. Pipes, junctions, incidents, affected districts, scenarios, and Agent recommendations are the visual subject. UI panels exist to help the operator understand the situation, preview consequences, confirm actions, and roll back decisions. They must never compete with the spatial context.
|
||||
|
||||
The visual direction is restrained technical immersion:
|
||||
|
||||
- Full-screen spatial workspace by default.
|
||||
- Clear operational hierarchy over decorative spectacle.
|
||||
- Light engineering map as the default canvas.
|
||||
- Floating translucent command surfaces with strong readability.
|
||||
- Blue as the primary action and selection color.
|
||||
- Green/cyan for normal network flow, orange for risk, red for incident and impact, purple only for model or Agent inference.
|
||||
- Motion used to explain spatial change, simulation progress, and recommended actions.
|
||||
|
||||
The design should be suitable for long daily use by dispatchers and engineers while still feeling modern, spatial, and intelligent.
|
||||
|
||||
## Experience Principles
|
||||
|
||||
### Map First
|
||||
|
||||
The first viewport is always the working map. Do not introduce a landing page, marketing hero, decorative splash screen, or card-heavy overview before the operator reaches the map.
|
||||
|
||||
The map must answer:
|
||||
|
||||
- What is happening now?
|
||||
- Where is it happening?
|
||||
- Which assets, districts, and users are affected?
|
||||
- What actions are recommended?
|
||||
- What will change if an action is applied?
|
||||
|
||||
### UI As Command Instruments
|
||||
|
||||
Panels, controls, and toolbars should feel like instruments floating over the map. They should be available when needed, collapsible when not needed, and positioned to preserve the operator's spatial awareness.
|
||||
|
||||
Fixed UI must avoid long-term obstruction of critical map objects. The map camera should reserve safe padding for open panels, especially during locate, fit bounds, and scenario preview operations.
|
||||
|
||||
### Explain Before Acting
|
||||
|
||||
Agent and simulation features must expose a decision chain:
|
||||
|
||||
1. Detect or receive task.
|
||||
2. Explain current evidence.
|
||||
3. Recommend one or more actions.
|
||||
4. Preview spatial and service impact.
|
||||
5. Ask for confirmation.
|
||||
6. Apply with progress feedback.
|
||||
7. Offer rollback or audit trail.
|
||||
|
||||
Never let the Agent directly mutate map state or operational state without an explicit preview and confirmation path.
|
||||
|
||||
### Dense, Not Crowded
|
||||
|
||||
This is a command workspace, so information density is expected. Density should come from compact typography, clear grouping, and predictable layout. It should not come from tiny illegible controls, overlapping surfaces, or excessive decoration.
|
||||
|
||||
### Restraint Over Spectacle
|
||||
|
||||
The product may feel technical and immersive, but it should not become a generic "sci-fi dashboard." Avoid gratuitous glow, animated circuit lines, decorative gradients, 3D ornaments, and full-screen dark neon themes unless a future mode explicitly requires them.
|
||||
|
||||
## Visual Language
|
||||
|
||||
### Tone
|
||||
|
||||
The interface should feel:
|
||||
|
||||
- Operational
|
||||
- Spatial
|
||||
- Calm under pressure
|
||||
- Trustworthy
|
||||
- Technically precise
|
||||
- AI-assisted but human-controlled
|
||||
|
||||
It should not feel:
|
||||
|
||||
- Like a marketing site
|
||||
- Like a generic admin dashboard
|
||||
- Like a decorative data wall
|
||||
- Like a chat app with a map attached
|
||||
|
||||
### Canvas
|
||||
|
||||
Use a light engineering-map canvas as the default environment. The map may use a real basemap, a vector tile basemap, or a fallback grid, but it should remain quiet enough for drainage-network layers to dominate.
|
||||
|
||||
Recommended canvas colors:
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `canvas-map` | `#eaf1f8` | Fallback map background |
|
||||
| `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid |
|
||||
| `canvas-page` | `#f8fafc` | Non-map support pages |
|
||||
| `canvas-panel` | `rgba(255, 255, 255, 0.76)` | Default floating panel |
|
||||
| `canvas-panel-strong` | `rgba(255, 255, 255, 0.92)` | Dense text or form surface |
|
||||
| `canvas-panel-soft` | `rgba(255, 255, 255, 0.58)` | Low-density spatial overlay |
|
||||
| `surface-overlay` | `rgba(255, 255, 255, 0.72-0.86)` | Floating menu or compact popover shell |
|
||||
| `surface-readable` | `rgba(255, 255, 255, 0.90-0.96)` | Readable rows, menu headers, and content blocks inside translucent shells |
|
||||
| `surface-map-control` | `rgba(255, 255, 255, 0.92)` | Always-visible and persistent map instruments: toolbar, zoom, scaleline, Agent shell, scheduled-condition shell |
|
||||
| `surface-tool-panel` | `rgba(255, 255, 255, 0.78)` | Toolbar function panel outer shell |
|
||||
|
||||
### Color System
|
||||
|
||||
#### Core UI Colors
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `action-blue` | `#2563eb` | Primary actions, selected tools, active scenario |
|
||||
| `action-blue-hover` | `#1d4ed8` | Hover and pressed primary action |
|
||||
| `action-blue-soft` | `#dbeafe` | Selected background, low-emphasis active state |
|
||||
| `ink` | `#0f172a` | Primary text |
|
||||
| `ink-secondary` | `#334155` | Secondary labels and values |
|
||||
| `ink-muted` | `#64748b` | Descriptions, metadata |
|
||||
| `ink-disabled` | `#94a3b8` | Disabled controls |
|
||||
| `border-soft` | `rgba(255, 255, 255, 0.62)` | Glass panel border |
|
||||
| `border-strong` | `rgba(148, 163, 184, 0.34)` | Dense control border |
|
||||
|
||||
#### Operational Colors
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `network-major` | `#0477bf` | Major pipes, DN600 and above |
|
||||
| `network-medium` | `#0aa6a6` | Medium pipes, DN300-DN600 |
|
||||
| `network-minor` | `#3dbf7f` | Minor pipes, below DN300 |
|
||||
| `network-closed` | `#9ca3af` | Closed or inactive assets |
|
||||
| `demand-low` | `#70c1b3` | Low demand junctions |
|
||||
| `demand-medium` | `#247ba0` | Medium demand junctions |
|
||||
| `demand-high` | `#f25f5c` | High demand junctions |
|
||||
| `risk` | `#ff7a45` | Risk preview, warning glow |
|
||||
| `incident` | `#ef4444` | Burst, outage, critical impact |
|
||||
| `success` | `#22c55e` | Improved scenario, completed action |
|
||||
| `agent` | `#7c3aed` | Agent/model inference only |
|
||||
|
||||
Color must carry meaning. Do not use orange, red, green, or purple as decoration.
|
||||
|
||||
### Typography
|
||||
|
||||
Use a system font stack that performs well for Chinese and English operational UI:
|
||||
|
||||
```css
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
```
|
||||
|
||||
Avoid negative letter spacing. This product should optimize for scanning dense operational data, not editorial display styling.
|
||||
|
||||
| Token | Size | Weight | Line Height | Use |
|
||||
|---|---:|---:|---:|---|
|
||||
| `title-lg` | 24px | 600 | 1.25 | Rare page or mode title |
|
||||
| `title-md` | 18px | 600 | 1.35 | Panel title |
|
||||
| `title-sm` | 16px | 600 | 1.35 | Section title |
|
||||
| `body` | 14px | 400 | 1.55 | Default UI text |
|
||||
| `body-strong` | 14px | 600 | 1.45 | Important labels and values |
|
||||
| `caption` | 12px | 400 | 1.45 | Metadata, descriptions |
|
||||
| `caption-strong` | 12px | 600 | 1.35 | Status labels, compact headers |
|
||||
| `metric` | 24px | 600 | 1.1 | Key numeric metric |
|
||||
| `map-label` | 12-14px | 600 | 1.2 | Map labels with halo |
|
||||
|
||||
Use large display typography sparingly. In the command workspace, oversized headings waste spatial context.
|
||||
|
||||
### Spacing
|
||||
|
||||
Use a 4px base unit with 8px as the main layout rhythm.
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `space-1` | 4px | Micro gaps |
|
||||
| `space-2` | 8px | Control gaps |
|
||||
| `space-3` | 12px | Compact groups |
|
||||
| `space-4` | 16px | Panel padding |
|
||||
| `space-5` | 20px | Large panel padding |
|
||||
| `space-6` | 24px | Major groups |
|
||||
| `space-8` | 32px | Panel separation |
|
||||
|
||||
Prefer compact layouts with predictable alignment. Do not nest cards inside cards unless the inner card is a repeated data item or a modal-like focus surface.
|
||||
|
||||
### Radius
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `rounded-sm` | 2px | Tiny state markers and very small swatches |
|
||||
| `rounded-lg` | 8px | Icon cells and compact visual anchors |
|
||||
| `rounded-xl` | 12px | Standard rows, readable blocks, compact controls, toolbar rails |
|
||||
| `rounded-2xl` | 16px | Major floating panels and toolbar function panels |
|
||||
| `rounded-full` | 9999px | Status pills, avatars, scenario chips |
|
||||
|
||||
Rounded corners should imply function. Do not make every object pill-shaped.
|
||||
|
||||
Use a clear radius hierarchy over the map:
|
||||
|
||||
- Major floating panels, including toolbar function panels, header dropdown menus, result panels, simulation panels, and feature detail popovers, use `rounded-2xl`.
|
||||
- Readable content blocks inside a major panel use `rounded-xl`.
|
||||
- Standard row buttons and compact control shells use `rounded-xl`.
|
||||
- Icon cells use `rounded-lg`, so they feel related to row buttons without becoming pill-like.
|
||||
- Always-visible compact instruments such as toolbar rails, zoom, floating basemap controls, and scaleline use `rounded-xl`.
|
||||
- Avoid arbitrary radius utilities such as `rounded-[14px]` unless a map-specific geometry requires a one-off value. Prefer Tailwind's default radius scale so inner and outer corners remain visually compatible.
|
||||
|
||||
### Elevation
|
||||
|
||||
Elevation should communicate interaction depth over the map.
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| `map` | No shadow | Basemap and business layers |
|
||||
| `control` | White 90-96%, soft border, small shadow | Zoom, basemap, tool buttons, persistent command shells |
|
||||
| `panel` | White 58-82%, backdrop blur, blue-gray shadow | Toolbar function panels, temporary result panels, layer panels |
|
||||
| `focus` | White 92-98%, stronger border and shadow | Popover, confirmation, detailed forms |
|
||||
| `modal` | Scrim plus focused surface | Destructive or irreversible decisions |
|
||||
|
||||
Recommended shadows:
|
||||
|
||||
- `control`: `0 14px 34px rgba(15, 23, 42, 0.18)`
|
||||
- `panel`: `0 24px 70px rgba(30, 64, 115, 0.20)`
|
||||
- `focus`: `0 30px 90px rgba(15, 23, 42, 0.26)`
|
||||
|
||||
Use `backdrop-filter: blur(20px)` only where the panel remains readable. If the map beneath is visually busy, increase surface opacity instead of increasing blur.
|
||||
|
||||
#### Transparency Layering
|
||||
|
||||
Use consistent opacity tiers for floating UI over the map:
|
||||
|
||||
- Outer shells such as dropdown menus, compact popovers, and temporary command surfaces should use `surface-overlay` around `rgba(255, 255, 255, 0.72-0.86)`.
|
||||
- Persistent command instruments that remain visible while the operator works, such as the Agent shell and scheduled-condition shell, should use `surface-map-control` at `rgba(255, 255, 255, 0.92)` rather than a low-opacity panel shell.
|
||||
- Inner readable content such as menu headers, selectable rows, status groups, and form blocks should use `surface-readable` around `rgba(255, 255, 255, 0.90-0.96)`.
|
||||
- Dense text, destructive confirmations, and detailed forms may use `canvas-panel-strong` around `rgba(255, 255, 255, 0.92)` when legibility matters more than map transparency.
|
||||
- Do not put long text directly on a low-opacity shell. Give it a local readable surface, or increase the shell opacity.
|
||||
- Keep readable content at least as opaque as its surrounding shell. Labels, values, and commands should sit on the more readable layer, not directly on a lower-opacity shell.
|
||||
|
||||
### Motion
|
||||
|
||||
Motion should make operational change understandable.
|
||||
|
||||
Use motion for:
|
||||
|
||||
- Locating an asset on the map.
|
||||
- Previewing affected areas.
|
||||
- Playing simulation time.
|
||||
- Expanding or collapsing command panels.
|
||||
- Showing Agent action preview and confirmation state.
|
||||
- Drawing attention to new incidents or warnings.
|
||||
|
||||
Avoid motion for:
|
||||
|
||||
- Decorative background movement.
|
||||
- Constant pulsing on non-critical objects.
|
||||
- Large parallax effects.
|
||||
- Slow transitions that delay dispatch work.
|
||||
|
||||
Recommended durations:
|
||||
|
||||
| Token | Duration | Use |
|
||||
|---|---:|---|
|
||||
| `fast` | 120-160ms | Button, hover, small controls |
|
||||
| `normal` | 180-260ms | Panel expand/collapse |
|
||||
| `map-camera` | 500-700ms | Fit bounds, locate feature |
|
||||
| `simulation-step` | 300-500ms | Time-step change |
|
||||
|
||||
Workbench panel motion should use `180ms` as the default speed when the component changes structural state, such as showing, hiding, expanding, collapsing, or changing columns. Use `cubic-bezier(0.22, 1, 0.36, 1)` for entrance and expansion, and `cubic-bezier(0.5, 0, 0.2, 1)` for exit. Keep micro-interactions such as hover states, row highlights, icon rotation, and compact button feedback at about `150ms`.
|
||||
|
||||
For detail content that appears after a structural panel transition starts, use a short fade of about `140-150ms` with no long delay. A delay around `60ms` is enough for the content to feel attached to the layout motion; avoid waiting until the full panel transition is finished unless the content would otherwise overlap or jump.
|
||||
|
||||
Toolbar function panels should use a fast, quiet entrance and exit: fade in/out, move 4-8px from the tool rail, and optionally scale between `0.95-1`. Keep the animation around `150ms` so tool switching feels responsive.
|
||||
|
||||
Scaleline motion should stay informational. Only the internal scale bar width may transition over about `120-200ms`; do not animate the outer auto-sized container or coordinate text.
|
||||
|
||||
For panels that expand from a compact list into a detail workspace, use the Collapsible Detail Panels pattern below: animate structure first, then reveal detail content after layout is stable.
|
||||
|
||||
## Immersive Layout Model
|
||||
|
||||
### Primary Regions
|
||||
|
||||
The workspace should be composed from five persistent regions:
|
||||
|
||||
| Region | Role | Behavior |
|
||||
|---|---|---|
|
||||
| Map Canvas | Primary spatial context | Always visible, full viewport |
|
||||
| Command Top Bar | System state | Thin, translucent, status-oriented |
|
||||
| Agent Command Panel | Reasoning and recommended actions | Left side, collapsible, task-focused |
|
||||
| Analysis / Layer Rail | Tools, layers, results | Right side, icon-first, panels open on demand |
|
||||
| Simulation Timeline | Scenario time and playback | Bottom dock, visible during simulation |
|
||||
|
||||
### Command Top Bar
|
||||
|
||||
The top bar is a status strip, not a marketing header.
|
||||
|
||||
It should contain:
|
||||
|
||||
- Product or workspace mark.
|
||||
- Current data time.
|
||||
- Model or scenario version.
|
||||
- Active scenario.
|
||||
- Alert count and severity.
|
||||
- User/session controls.
|
||||
|
||||
It should not contain:
|
||||
|
||||
- Large page title.
|
||||
- Promotional copy.
|
||||
- Decorative hero content.
|
||||
- Navigation unrelated to the current operation.
|
||||
|
||||
Recommended height: 56-68px.
|
||||
|
||||
Dropdown menus opened from the top bar behave as floating panels, not compact controls. Use `rounded-2xl` for the outer menu, `rounded-xl` for readable menu header blocks and rows, and `rounded-lg` for icon cells.
|
||||
|
||||
### Collapsible Detail Panels
|
||||
|
||||
Use this pattern for persistent map instruments that start as compact lists and expand into a wider inspection surface.
|
||||
|
||||
- Keep the compact state focused on the primary thing the operator is already scanning. Hide secondary groups until expanded if they would distract from the compact task.
|
||||
- Preserve the primary list DOM and scroll container across collapse and expansion. Do not replace a collapsed list with a separate expanded list if the user is expected to continue reading the same records.
|
||||
- Expand by changing shell width and internal column widths, not by pushing content vertically into place.
|
||||
- Delay detail content entrance until the width transition is complete. A short fade is acceptable after the layout is stable; vertical slide-in for dense text is not.
|
||||
- Keep the list column width and row typography consistent between collapsed and expanded states unless the task changes substantially.
|
||||
- The expanded state may add a left or right detail region, but the list should remain in a predictable position so selection context is not lost.
|
||||
|
||||
### Agent Command Panel
|
||||
|
||||
The Agent panel is the main decision companion. It should make the Agent auditable and controllable.
|
||||
|
||||
On desktop, the Agent panel may be wider than a utility side panel because it carries the auditable decision chain. Use a preferred width around `460px`, with room to expand up to about `520px` on wide command workstations when the map camera reserves matching left padding. Do not widen it on medium or small screens; there it should become narrower or overlay on demand.
|
||||
|
||||
The Agent panel is a persistent command instrument, so its outer shell should use the same control surface family as the map toolbar, zoom control, and scheduled-condition panel. It is not a low-opacity temporary glass panel.
|
||||
|
||||
| Agent surface | Opacity | Use |
|
||||
|---|---:|---|
|
||||
| Outer shell | `rgba(255, 255, 255, 0.92)` | Main floating panel, collapsed rail, and mobile entry. Reuse the map control surface treatment: soft white border, compact control shadow, subtle dark ring, and backdrop blur. |
|
||||
| Header, context strip, action tray, input band | `rgba(255, 255, 255, 0.96)` | Persistent readable bands that organize the command surface. |
|
||||
| Conversation well | `rgba(255, 255, 255, 0.34)` | Low-density scroll area behind messages. Do not place text directly on this layer. |
|
||||
| Message and recommendation surfaces | `rgba(255, 255, 255, 0.94)` | Assistant messages, recommendation groups, and primary readable content. |
|
||||
| Nested evidence/tool result blocks | `rgba(248, 250, 252, 0.90)` | Evidence tiles, tool run rows, and compact structured outputs inside messages. |
|
||||
| Prompt input and secondary controls | `rgba(255, 255, 255, 0.96)` | Text entry, secondary buttons, and controls that need crisp hit targets. |
|
||||
| Active confirmation surface | `rgba(255, 247, 237, 0.96)` | Agent action review and confirmation state. |
|
||||
|
||||
Do not return the Agent shell to a separate `0.78` panel treatment while the toolbar, zoom, and scheduled-condition panel use `surface-map-control`; those surfaces should read as one instrument family. Keep the conversation well lighter than the shell, and use readable local message surfaces for operational text. Do not use `0.58` or lower for any Agent region that contains operational text.
|
||||
|
||||
Collapsed Agent state should be content-sized, not full height. It should keep only the expand button, Agent identity, and pending status indicators. Expanding and collapsing should use a short `150-180ms` reveal: opacity, `4-8px` horizontal translation, and very small scale change. Do not animate the map camera when the Agent panel changes state.
|
||||
|
||||
Required sections:
|
||||
|
||||
- Current task or operator prompt.
|
||||
- Evidence summary.
|
||||
- Step-by-step reasoning or plan.
|
||||
- Recommended actions.
|
||||
- Preview controls.
|
||||
- Confirmation controls.
|
||||
- Conversation or command input.
|
||||
|
||||
Agent recommendation cards should show:
|
||||
|
||||
- Action name.
|
||||
- Target assets or area.
|
||||
- Expected effect.
|
||||
- Risk or confidence.
|
||||
- Primary action: preview.
|
||||
- Secondary action: inspect details.
|
||||
|
||||
The Agent should use purple only for model identity or inference metadata. Primary operational action buttons should remain blue.
|
||||
|
||||
### Scheduled Condition Panel
|
||||
|
||||
The scheduled-condition panel is a persistent right-side command instrument for live operational tasks. It should visually belong to the same family as the toolbar, zoom control, and Agent shell, not to temporary tool panels.
|
||||
|
||||
Use the same shell treatment as `surface-map-control`:
|
||||
|
||||
- Outer shell: `rgba(255, 255, 255, 0.92)`, soft white border, subtle dark ring, compact control shadow, and `backdrop-blur-xl`.
|
||||
- Outer radius: `rounded-2xl`, because the component reads as a major floating panel even when collapsed.
|
||||
- Header, list containers, rows, and detail cards: `surface-readable` or `canvas-panel-strong`, around `0.94-0.96`.
|
||||
- Icon cells: `rounded-lg`, matching toolbar icon cells.
|
||||
- Rows and compact controls: `rounded-xl`.
|
||||
- Detail content blocks: `rounded-xl` where the block carries multi-line text or structured details.
|
||||
|
||||
Collapsed state:
|
||||
|
||||
- Keep the compact width list-focused, with enough height to reveal roughly six recent rows before scrolling.
|
||||
- Show recent conditions only. Do not show scheduled work orders in the collapsed state; they are secondary to the compact scanning task.
|
||||
- The header should reflect the compact content, such as recent-condition count. Avoid advertising hidden scheduled-work content unless the expanded state is open.
|
||||
- Use the same timeline row component and scroll container that the expanded state uses, so the operator does not lose list position when expanding.
|
||||
- Row text should use compact 12px typography, with time on the left, status on the right, and a visible timeline marker for temporal scanning.
|
||||
|
||||
Expanded state:
|
||||
|
||||
- Add a separate detail region beside the timeline list. The detail region may use the newly available horizontal space for evidence, Agent analysis, recommended options, and operational metadata.
|
||||
- Keep the timeline list column width and row typography close to the collapsed state. The list should feel like the same instrument after expansion.
|
||||
- Show scheduled work orders as a separate compact group above recent conditions only in expanded state. If there are many work orders, keep that group scrollable and height-limited.
|
||||
- Recent conditions sort newest to oldest. Today scheduled work sorts by start time.
|
||||
- Detail panel height should be content-driven within viewport limits; avoid large empty areas.
|
||||
|
||||
Details:
|
||||
|
||||
- Historical condition details may include evidence, Agent recommendation, model/session metadata, risk, duration, and a continue-conversation action.
|
||||
- Running condition details should be compact. The continue-conversation action is disabled until the task completes.
|
||||
- Scheduled work orders are operational work details, not Agent sessions. Do not show model, session, Agent recommendation, or evidence chain for work orders.
|
||||
- Work orders should show start time, estimated duration, computed end time, work content, execution mode, and due-time action.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Hide the scheduled-condition panel when a right-side tool panel opens or when the operator performs a workspace action that would conflict spatially.
|
||||
- Mobile v1 should not show this panel unless a dedicated compact pattern is designed.
|
||||
- Show and hide the whole panel with a short instrument transition from the toolbar side: opacity, slight horizontal/vertical translation, and very small scale change at `180ms`.
|
||||
- Collapse and expand the internal detail layout with a smooth width or grid-column transition around `180ms`. Reveal detail content only after the layout has settled.
|
||||
|
||||
### Analysis Panel
|
||||
|
||||
The analysis panel presents results of simulations, incidents, and selections.
|
||||
|
||||
For incident impact, prioritize:
|
||||
|
||||
- Affected households.
|
||||
- Affected districts.
|
||||
- Critical assets.
|
||||
- Estimated recovery time.
|
||||
- Valves or pipe segments involved.
|
||||
- Confidence or data freshness.
|
||||
|
||||
Metric cards should be compact and comparable. Use one large numeric value, one short label, and one optional trend/status indicator.
|
||||
|
||||
### Layer And Tool Rail
|
||||
|
||||
The tool rail should be icon-first and compact. Common tools stay visible; low-frequency tools go behind a menu.
|
||||
|
||||
Recommended persistent tools:
|
||||
|
||||
- Layers
|
||||
- Measure
|
||||
- Analysis
|
||||
- Annotation
|
||||
- Locate/search
|
||||
- More
|
||||
|
||||
Each icon button must have `aria-label` and a tooltip. Active state must be visually obvious through color and background, not only by icon color.
|
||||
|
||||
### Map Core Controls
|
||||
|
||||
Reusable controls under `features/map/core/components` should share one compact map-instrument language.
|
||||
|
||||
#### Control Surface
|
||||
|
||||
Zoom, toolbar, scaleline, and other always-visible map controls should use the `control` elevation level:
|
||||
|
||||
- Surface: use `surface-map-control`, fixed at `rgba(255, 255, 255, 0.92)`.
|
||||
- Border: white at roughly 70-80% opacity plus a very subtle dark ring when extra definition is needed.
|
||||
- Shadow: soft multi-layer map shadow, not card-like depth.
|
||||
- Backdrop blur: use lightly, only when text and icons remain readable.
|
||||
- Radius: `rounded-xl` for grouped controls; `rounded-lg` for internal icon buttons.
|
||||
- Do not stack a `command-panel` or second glass wrapper around toolbar, zoom, Agent shell, or scheduled-condition shell.
|
||||
|
||||
Prefer Tailwind default opacity utilities for UI component surfaces, such as `bg-white/95`, `bg-blue-50/95`, and `shadow-lg shadow-slate-900/20`. Keep explicit color strings only for platform APIs such as MapLibre paint values, runtime dynamic `style` values, syntax-highlighting variables, or rare cases where an audited opacity cannot be represented clearly by Tailwind defaults.
|
||||
|
||||
#### Zoom Control
|
||||
|
||||
The zoom control should remain a small vertical instrument:
|
||||
|
||||
- Width around `46px`.
|
||||
- Icon buttons around `38px`.
|
||||
- Buttons are flat and visually consistent.
|
||||
- Do not show zoom readout unless a future workflow explicitly needs it.
|
||||
- Use short centered horizontal separators between buttons.
|
||||
- Button hover uses a soft blue background and subtle ring; do not add per-button lift shadows.
|
||||
- The whole control, not each button, carries the elevation.
|
||||
|
||||
#### Tool Rail
|
||||
|
||||
The map toolbar should follow the same visual system as zoom:
|
||||
|
||||
- Vertical rail width around `46px`; horizontal rail uses the same button size.
|
||||
- Icon-only buttons, with `aria-label` and tooltip.
|
||||
- Use short centered separators: horizontal dashes for vertical rails, vertical ticks for horizontal rails.
|
||||
- Default buttons are flat; hover uses soft blue background and subtle ring.
|
||||
- Active tools use blue fill with white icon/text and no heavy shadow.
|
||||
- Badges may use red only for operational alerts or counts that require attention.
|
||||
- The rail shell uses `surface-map-control` (`rgba(255, 255, 255, 0.92)`) and should not sit inside another translucent panel.
|
||||
|
||||
Opening a tool rail panel should not move the map camera by default. Treat tool panels as floating overlays unless they become persistent major panels that materially obstruct map work.
|
||||
|
||||
When a tool rail panel opens, closes, or changes active tool, animate the panel as a short instrument reveal or dismissal from the rail side. Use opacity, slight horizontal translation, and a very small scale change only; avoid large sliding drawers.
|
||||
|
||||
#### Layer And Basemap Panel
|
||||
|
||||
Layer visibility and basemap selection belong together in the Layers tool panel:
|
||||
|
||||
- Business layer visibility appears first.
|
||||
- Basemap selection appears below business layers.
|
||||
- Toolbar function panels use `surface-tool-panel` (`rgba(255, 255, 255, 0.78)`) for the outer shell.
|
||||
- Internal list rows, panel headers, swatches, result blocks, and action rows use `surface-readable`, around `0.94-0.96`, for readability.
|
||||
- MVT sources should keep `bounds` where available to avoid invalid tile requests. This must not be confused with map view extent; the map view itself should remain unrestricted unless a workflow explicitly requires a view constraint.
|
||||
|
||||
#### Scaleline
|
||||
|
||||
The scaleline should read as a map instrument, not a dashboard footer:
|
||||
|
||||
- It may retain technical metadata such as zoom, projection, coordinates, and attribution when useful.
|
||||
- Width should be content-driven, not fixed.
|
||||
- When positioned at the bottom-right edge, it may touch the viewport edge directly.
|
||||
- If touching the bottom-right edge, only the top-left corner should be rounded.
|
||||
- Use the same `surface-map-control` opacity as toolbar and zoom so the bottom-right instrument cluster reads as one family.
|
||||
- Coordinate readout should match the displayed projection. If the scaleline labels `EPSG:3857`, prioritize visible projected `X/Y`; longitude and latitude may remain as secondary debug context, not the primary readout.
|
||||
- Use only a short, smooth transition for the internal scale bar width. Keep metadata widths stable; do not animate the outer auto-sized container or coordinate text.
|
||||
|
||||
### Simulation Timeline
|
||||
|
||||
The timeline appears when simulation or replay mode is active.
|
||||
|
||||
It should include:
|
||||
|
||||
- Current simulation time.
|
||||
- Play/pause.
|
||||
- Previous/next step.
|
||||
- Speed.
|
||||
- Scenario selector.
|
||||
- Comparison summary.
|
||||
- Export/report action.
|
||||
|
||||
The timeline should read as a control deck over the map, not as a separate dashboard section.
|
||||
|
||||
### Feature Popover
|
||||
|
||||
Clicking or hovering a pipe, junction, valve, or district should open a compact spatial popover.
|
||||
|
||||
It should include:
|
||||
|
||||
- Object type.
|
||||
- Object name or id.
|
||||
- Key status.
|
||||
- 3-5 most important attributes.
|
||||
- Related actions such as locate, inspect, isolate, add to scenario, or ask Agent.
|
||||
|
||||
Do not open a large side panel for every selection unless the operator explicitly asks for details.
|
||||
|
||||
## Map Layer Semantics
|
||||
|
||||
### Pipes
|
||||
|
||||
Pipe color represents category or operational state. Width represents diameter or importance.
|
||||
|
||||
| Pipe State | Color | Guidance |
|
||||
|---|---:|---|
|
||||
| Major pipe | `network-major` | Strong blue, highest visual weight |
|
||||
| Medium pipe | `network-medium` | Cyan/teal |
|
||||
| Minor pipe | `network-minor` | Green |
|
||||
| Closed pipe | `network-closed` | Muted gray |
|
||||
| Hover/selected | Dark ink overlay or blue halo | Must remain legible over all basemaps |
|
||||
| Risk preview | `risk` glow | Temporary, tied to preview state |
|
||||
|
||||
### Junctions
|
||||
|
||||
Junctions should remain smaller than pipes unless selected or critical. Use halo and stroke to preserve visibility.
|
||||
|
||||
Demand intensity may use a green to blue to red ramp:
|
||||
|
||||
- Low: `demand-low`
|
||||
- Medium: `demand-medium`
|
||||
- High: `demand-high`
|
||||
|
||||
### Incidents And Impact
|
||||
|
||||
Incidents require the strongest visual hierarchy.
|
||||
|
||||
- Burst point: red point with white or pale red halo.
|
||||
- Impact area: red fill at low opacity, red dashed or solid outline.
|
||||
- Critical label: red text or red halo only where necessary.
|
||||
- Warning preview: orange glow, not red, until confirmed as an incident.
|
||||
|
||||
Red should be reserved for confirmed critical state. Overuse of red will reduce emergency legibility.
|
||||
|
||||
### Labels
|
||||
|
||||
Map labels must remain readable on both basemap and fallback grid.
|
||||
|
||||
Use:
|
||||
|
||||
- White halo for dark text.
|
||||
- Blue halo for white pipe labels.
|
||||
- 12-14px text.
|
||||
- Overlap only for critical incident labels.
|
||||
|
||||
## State Modes
|
||||
|
||||
The workspace should have clear visual modes.
|
||||
|
||||
### Normal Mode
|
||||
|
||||
Purpose: monitor and inspect.
|
||||
|
||||
- Light map canvas.
|
||||
- Network colors visible but calm.
|
||||
- Panels minimized or compact.
|
||||
- No persistent warning animation.
|
||||
|
||||
### Alert Mode
|
||||
|
||||
Purpose: respond to active anomaly or incident.
|
||||
|
||||
- Top bar alert indicator becomes prominent.
|
||||
- Related asset and area are emphasized.
|
||||
- Agent panel may open with incident context.
|
||||
- Red appears only around confirmed critical objects.
|
||||
|
||||
### Simulation Mode
|
||||
|
||||
Purpose: compare future or hypothetical outcomes.
|
||||
|
||||
- Bottom timeline appears.
|
||||
- Scenario chips become visible.
|
||||
- Simulation layers and impact overlays are enabled.
|
||||
- The user can scrub, pause, compare, and export.
|
||||
|
||||
### Agent Preview Mode
|
||||
|
||||
Purpose: inspect an action before confirmation.
|
||||
|
||||
- Proposed affected assets are highlighted.
|
||||
- Expected impact is shown as temporary overlay.
|
||||
- Confirmation controls become visible.
|
||||
- Existing operational state remains distinguishable from preview state.
|
||||
|
||||
### Confirmation Mode
|
||||
|
||||
Purpose: prevent accidental operational change.
|
||||
|
||||
- Use a focused confirmation surface.
|
||||
- Summarize action, target, impact, risk, and rollback availability.
|
||||
- Require explicit confirmation for destructive or irreversible actions.
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
### Buttons
|
||||
|
||||
Primary buttons:
|
||||
|
||||
- Blue fill.
|
||||
- White text.
|
||||
- 40-44px height for important actions.
|
||||
- Used for confirm, preview, run simulation, export report.
|
||||
|
||||
Secondary buttons:
|
||||
|
||||
- White or translucent fill.
|
||||
- Blue or slate text.
|
||||
- Border or subtle ring.
|
||||
- Used for inspect, cancel, locate, details.
|
||||
|
||||
Danger buttons:
|
||||
|
||||
- Red only for destructive or emergency actions.
|
||||
- Must include confirmation for operational changes.
|
||||
|
||||
Icon buttons:
|
||||
|
||||
- Prefer lucide icons.
|
||||
- Minimum 32px target; 40px for important map tools.
|
||||
- Always include accessible label and tooltip.
|
||||
|
||||
### Cards
|
||||
|
||||
Cards are for repeated data items, metrics, recommendations, and compact summaries. Do not use cards as generic page sections.
|
||||
|
||||
Metric card structure:
|
||||
|
||||
- Label.
|
||||
- Value.
|
||||
- Unit.
|
||||
- Optional trend/status icon.
|
||||
|
||||
Recommendation card structure:
|
||||
|
||||
- Recommendation title.
|
||||
- Target.
|
||||
- Expected effect.
|
||||
- Risk/confidence.
|
||||
- Preview action.
|
||||
|
||||
### Inputs
|
||||
|
||||
Inputs should be compact and command-oriented.
|
||||
|
||||
Use inputs for:
|
||||
|
||||
- Agent prompt.
|
||||
- Asset search.
|
||||
- Scenario name.
|
||||
- Numeric simulation parameters.
|
||||
|
||||
Do not make the Agent input visually dominate the workspace. It is one command path, not the product itself.
|
||||
|
||||
### Badges And Pills
|
||||
|
||||
Use pills for:
|
||||
|
||||
- Status.
|
||||
- Severity.
|
||||
- Scenario.
|
||||
- Data freshness.
|
||||
- Model version.
|
||||
|
||||
Avoid pill-shaped decoration with no operational meaning.
|
||||
|
||||
## Accessibility And Readability
|
||||
|
||||
- Text on glass panels must pass practical contrast against busy map backgrounds.
|
||||
- Increase panel opacity when content density increases.
|
||||
- Do not rely on color alone; pair color with icon, label, shape, or position.
|
||||
- Maintain visible focus states for keyboard use.
|
||||
- Important controls should be reachable with predictable tab order.
|
||||
- Use `aria-label` for icon-only controls.
|
||||
- Avoid truncating critical identifiers without tooltip or detail access.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Desktop Command Workspace
|
||||
|
||||
Default target: desktop and large tablet.
|
||||
|
||||
- Full map remains visible.
|
||||
- Left Agent panel and right analysis/tool panel may be open simultaneously.
|
||||
- Bottom timeline appears in simulation mode.
|
||||
- Map camera respects open panel padding.
|
||||
|
||||
### Medium Screens
|
||||
|
||||
- Right analysis panel should collapse into tool rail.
|
||||
- Agent panel may become narrower or overlay on demand.
|
||||
- Timeline should reduce secondary controls behind menus.
|
||||
|
||||
### Small Screens
|
||||
|
||||
Small screens are supported for inspection and lightweight response, not full dispatch.
|
||||
|
||||
- One major panel open at a time.
|
||||
- Tool rail becomes bottom or side compact controls.
|
||||
- Timeline controls simplify to play/pause, current time, and scenario.
|
||||
- Avoid dense side-by-side metric grids.
|
||||
|
||||
## Do's And Don'ts
|
||||
|
||||
### Do
|
||||
|
||||
- Make the map the first and strongest visual signal.
|
||||
- Use floating panels as operational instruments.
|
||||
- Preserve spatial context during Agent and simulation workflows.
|
||||
- Use red only for confirmed critical state.
|
||||
- Use orange for risk preview and warning state.
|
||||
- Keep typography compact and readable.
|
||||
- Use motion to explain spatial or temporal change.
|
||||
- Give every Agent action a preview and confirmation path.
|
||||
|
||||
### Don't
|
||||
|
||||
- Do not build a landing page as the first screen.
|
||||
- Do not use Apple-style product tiles, product photography, or oversized marketing typography.
|
||||
- Do not use decorative gradients, glowing ornaments, or animated backgrounds as a substitute for information hierarchy.
|
||||
- Do not let the Agent mutate state invisibly.
|
||||
- Do not bury map controls inside generic cards.
|
||||
- Do not use colors without operational meaning.
|
||||
- Do not allow fixed panels to permanently cover critical map areas.
|
||||
- Do not make the UI so transparent that text becomes hard to read.
|
||||
|
||||
## Design Review Checklist
|
||||
|
||||
Use this checklist for future UI changes:
|
||||
|
||||
- The map is visible and useful in the first viewport.
|
||||
- The main task can be understood within three seconds.
|
||||
- Critical incident, warning, normal, and preview states are visually distinct.
|
||||
- Open panels do not hide the target area without camera compensation.
|
||||
- Agent recommendations include evidence, preview, confirmation, and rollback/audit path.
|
||||
- Primary actions use blue unless they are destructive.
|
||||
- Red is reserved for confirmed incident or destructive action.
|
||||
- Text is readable over the map at desktop and mobile widths.
|
||||
- Icon-only controls have labels and tooltips.
|
||||
- Motion helps the user understand change.
|
||||
- No component adds decorative chrome without operational value.
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
The codebase should eventually align to this document rather than letting current prototype components define the design language.
|
||||
|
||||
Recommended implementation direction:
|
||||
|
||||
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`.
|
||||
- Convert repeated glass surfaces, map controls, metric cards, status pills, and action cards into reusable typed 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.
|
||||
- Treat Agent actions as typed data that the workbench can preview, validate, confirm, apply, and roll back.
|
||||
|
||||
### Component Library Strategy
|
||||
|
||||
Use a headless or primitive component approach rather than adopting a fully styled application component library as the product's main UI system.
|
||||
|
||||
Recommended stack:
|
||||
|
||||
- Use Radix UI primitives for accessibility, keyboard behavior, focus management, portals, menus, dialogs, tooltips, selects, accordions, and other reusable interaction patterns.
|
||||
- Use shadcn/ui-style copied components as a starting point when useful, but treat the copied source as project-owned code that must be restyled to this document.
|
||||
- Use Tailwind CSS and shared design tokens for the visual layer. Prefer Tailwind default classes for radius, type scale, spacing, opacity, shadows, and semantic UI colors. Keep explicit color strings only where the platform API requires them, such as MapLibre paint values, runtime dynamic styles, syntax-highlighting variables, or rare audited opacity cases that Tailwind defaults cannot express clearly.
|
||||
- Use `lucide-react` for iconography unless a domain-specific map symbol requires custom drawing.
|
||||
- Use headless engines for complex behavior when appropriate, such as TanStack Table for sortable, filterable, selectable operational tables.
|
||||
|
||||
Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component suite as the primary component library. Their default visual systems, spacing, radius, elevation, and layout assumptions conflict with the map-first command workspace and would require broad overrides.
|
||||
|
||||
Target component layering:
|
||||
|
||||
```txt
|
||||
shared/ui
|
||||
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
|
||||
|
||||
features/map/core/components
|
||||
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
|
||||
|
||||
features/workbench/components
|
||||
Water-network workbench components composed from shared UI and map core controls.
|
||||
|
||||
features/agent/components
|
||||
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
|
||||
```
|
||||
|
||||
The goal is to avoid repeating interaction and accessibility work while keeping full control over the visual language defined here.
|
||||
|
||||
## Known Open Decisions
|
||||
|
||||
- Dark mode is not specified in this version. A future night-operations mode should be designed deliberately rather than generated by inverting colors.
|
||||
- Brand identity, logo, and exact Chinese product name are not finalized here.
|
||||
- Mobile dispatch workflows need product decisions before becoming a full design spec.
|
||||
- Real operational approval, audit, and rollback requirements should be validated with stakeholders before implementation.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Drainage Network Frontend
|
||||
|
||||
排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问地址为 <http://localhost:3000>。
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
```
|
||||
|
||||
地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。
|
||||
@@ -0,0 +1,58 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #eaf1f8;
|
||||
--canvas-map: #eaf1f8;
|
||||
--canvas-map-grid: rgba(37, 99, 235, 0.06);
|
||||
--canvas-panel: rgba(255, 255, 255, 0.76);
|
||||
--canvas-panel-strong: rgba(255, 255, 255, 0.92);
|
||||
--action-blue: #2563eb;
|
||||
--action-blue-hover: #1d4ed8;
|
||||
--action-blue-soft: #dbeafe;
|
||||
--ink: #0f172a;
|
||||
--ink-secondary: #334155;
|
||||
--ink-muted: #64748b;
|
||||
--border-soft: rgba(255, 255, 255, 0.62);
|
||||
--border-strong: rgba(148, 163, 184, 0.34);
|
||||
--shadow-control: 0 14px 34px rgba(15, 23, 42, 0.18);
|
||||
--shadow-panel: 0 24px 70px rgba(30, 64, 115, 0.2);
|
||||
--shadow-focus: 0 30px 90px rgba(15, 23, 42, 0.26);
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 214 32% 91%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #0f172a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.08)),
|
||||
linear-gradient(90deg, var(--canvas-map-grid) 1px, transparent 1px),
|
||||
linear-gradient(var(--canvas-map-grid) 1px, transparent 1px),
|
||||
var(--canvas-map);
|
||||
background-size:
|
||||
auto,
|
||||
68px 68px,
|
||||
68px 68px,
|
||||
auto;
|
||||
}
|
||||
|
||||
.command-panel {
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--canvas-panel);
|
||||
box-shadow: var(--shadow-panel);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.agent-panel-band {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.agent-panel-conversation {
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.agent-panel-message {
|
||||
border: 1px solid rgba(226, 232, 240, 0.72);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.agent-streaming-response {
|
||||
display: block;
|
||||
--sd-animation: agent-stream-token-in;
|
||||
--sd-duration: 260ms;
|
||||
--sd-easing: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.agent-chart-line {
|
||||
animation: agent-chart-line-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-origin: center;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-chart-point,
|
||||
.agent-chart-bar {
|
||||
animation: agent-chart-mark-in 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center bottom;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-panel-nested {
|
||||
border: 1px solid rgba(203, 213, 225, 0.72);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.agent-panel-control {
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.agent-panel-icon-button {
|
||||
border: 1px solid rgba(203, 213, 225, 0.72);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.agent-panel-icon-button:hover {
|
||||
border-color: rgba(37, 99, 235, 0.26);
|
||||
background: rgba(239, 246, 255, 0.94);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.agent-panel-primary-icon,
|
||||
.agent-panel-primary-action {
|
||||
border: 1px solid rgba(37, 99, 235, 0.24);
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.agent-panel-primary-icon:hover,
|
||||
.agent-panel-primary-action:hover {
|
||||
border-color: rgba(29, 78, 216, 0.34);
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.agent-panel-primary-icon:disabled,
|
||||
.agent-panel-primary-action:disabled {
|
||||
background: #93c5fd;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.scheduled-feed-scroll,
|
||||
.scheduled-feed-detail-scroll,
|
||||
.agent-conversation-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 116, 139, 0.26) transparent;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.scheduled-feed-scroll::-webkit-scrollbar,
|
||||
.scheduled-feed-detail-scroll::-webkit-scrollbar,
|
||||
.agent-conversation-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scheduled-feed-scroll::-webkit-scrollbar-track,
|
||||
.scheduled-feed-detail-scroll::-webkit-scrollbar-track,
|
||||
.agent-conversation-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scheduled-feed-scroll::-webkit-scrollbar-thumb,
|
||||
.scheduled-feed-detail-scroll::-webkit-scrollbar-thumb,
|
||||
.agent-conversation-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.24);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scheduled-feed-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.scheduled-feed-detail-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.agent-conversation-scroll:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.36);
|
||||
}
|
||||
|
||||
.scheduled-feed-panel-shell {
|
||||
transition:
|
||||
width 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
max-height 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.scheduled-feed-detail-enter {
|
||||
animation: scheduled-feed-detail-show 140ms ease-out 180ms both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.scheduled-feed-layout {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
column-gap: 0;
|
||||
transition:
|
||||
grid-template-columns 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
column-gap 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: grid-template-columns;
|
||||
}
|
||||
|
||||
.scheduled-feed-layout-expanded {
|
||||
grid-template-columns: minmax(0, 1fr) 432px;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scheduled-feed-layout-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
column-gap: 0;
|
||||
}
|
||||
|
||||
.scheduled-feed-shell-enter {
|
||||
animation:
|
||||
scheduled-feed-shell-show 170ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
agent-panel-fade 140ms ease-out;
|
||||
transform-origin: top right;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.scheduled-feed-shell-exit {
|
||||
animation:
|
||||
scheduled-feed-shell-hide 170ms cubic-bezier(0.5, 0, 0.2, 1) forwards,
|
||||
agent-panel-dim 140ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
transform-origin: top right;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-panel-confirmation {
|
||||
border: 1px solid rgba(254, 215, 170, 0.92);
|
||||
background: rgba(255, 247, 237, 0.96);
|
||||
}
|
||||
|
||||
.agent-panel-enter {
|
||||
animation:
|
||||
agent-panel-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
agent-panel-fade 150ms ease-out;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-panel-collapse {
|
||||
animation:
|
||||
agent-panel-dismiss 170ms cubic-bezier(0.5, 0, 0.2, 1) forwards,
|
||||
agent-panel-dim 150ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-rail-enter {
|
||||
animation:
|
||||
agent-rail-shell 170ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
agent-panel-fade 130ms ease-out;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-rail-item {
|
||||
animation: agent-rail-item 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.agent-rail-item:nth-child(2) {
|
||||
animation-delay: 24ms;
|
||||
}
|
||||
|
||||
.agent-rail-item:nth-child(3) {
|
||||
animation-delay: 42ms;
|
||||
}
|
||||
|
||||
.agent-rail-item:nth-child(4) {
|
||||
animation-delay: 58ms;
|
||||
}
|
||||
|
||||
@keyframes agent-panel-reveal {
|
||||
from {
|
||||
transform: translateX(-8px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-panel-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-panel-dim {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-panel-dismiss {
|
||||
from {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-rail-shell {
|
||||
from {
|
||||
transform: translateX(-5px) scale(0.94);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-rail-item {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px) scale(0.92);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-stream-token-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(0.75px);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 0.88;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-chart-line-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-chart-mark-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scaleY(0.75);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scheduled-feed-detail-show {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scheduled-feed-shell-show {
|
||||
from {
|
||||
transform: translateX(10px) translateY(-4px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scheduled-feed-shell-hide {
|
||||
from {
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(10px) translateY(-4px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-panel-enter,
|
||||
.agent-panel-collapse,
|
||||
.agent-rail-enter,
|
||||
.agent-rail-item,
|
||||
.scheduled-feed-shell-enter,
|
||||
.scheduled-feed-shell-exit,
|
||||
.scheduled-feed-detail-enter {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-layout {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-panel-shell {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.agent-streaming-response [data-sd-animate] {
|
||||
animation: none;
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.agent-chart-line,
|
||||
.agent-chart-point,
|
||||
.agent-chart-bar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 14px 36px rgba(30, 64, 115, 0.14) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl button {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
border-radius: 8px 0 0 0 !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } 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 default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body>
|
||||
{children}
|
||||
<MapToaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MapWorkbenchPage } from "@/features/workbench";
|
||||
|
||||
export default function Home() {
|
||||
return <MapWorkbenchPage />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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: ["./"]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
describe("Agent API client sessions", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lists normalized sessions in newest-first order", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
sessions: [
|
||||
{ id: "old", title: "", created_at: 100, updated_at: 120 },
|
||||
{ id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" }
|
||||
]
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const sessions = await createAgentApiClient("http://agent.local").listSessions();
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{
|
||||
id: "new",
|
||||
title: "新会话",
|
||||
createdAt: 200,
|
||||
updatedAt: 210,
|
||||
status: undefined,
|
||||
parentSessionId: undefined,
|
||||
isStreaming: undefined,
|
||||
runStatus: "running"
|
||||
},
|
||||
{
|
||||
id: "old",
|
||||
title: "新对话",
|
||||
createdAt: 100,
|
||||
updatedAt: 120,
|
||||
status: undefined,
|
||||
parentSessionId: undefined,
|
||||
isStreaming: undefined,
|
||||
runStatus: undefined
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when loading a missing session", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 })));
|
||||
|
||||
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back when a candidate is not the agent service", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: [],
|
||||
components: [],
|
||||
actions: []
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: [],
|
||||
components: [],
|
||||
actions: []
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://not-agent.local/api/v1/agent/chat/ui-registry",
|
||||
undefined
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
|
||||
});
|
||||
|
||||
it("sends title update payloads using backend field names", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", {
|
||||
isTitleManuallyEdited: true
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://agent.local/api/v1/agent/chat/session/session-1/title",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
title: "标题",
|
||||
is_title_manually_edited: true
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("streams session events from the backend SSE endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
[
|
||||
'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n',
|
||||
'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n',
|
||||
'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n'
|
||||
].join(""),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
const events: unknown[] = [];
|
||||
|
||||
await createAgentApiClient("http://agent.local").streamSession("session-1", {
|
||||
onEvent: (event) => events.push(event)
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "state",
|
||||
sessionId: "session-1",
|
||||
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }],
|
||||
isStreaming: true,
|
||||
runStatus: "running"
|
||||
},
|
||||
{
|
||||
type: "token",
|
||||
sessionId: "session-1",
|
||||
data: { session_id: "session-1", content: "处理中" }
|
||||
},
|
||||
{
|
||||
type: "done",
|
||||
sessionId: "session-1",
|
||||
data: { session_id: "session-1", duration_ms: 20 }
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||
|
||||
export type AgentModelOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: "bolt" | "sparkle";
|
||||
};
|
||||
|
||||
export type AgentModelsResponse = {
|
||||
defaultModel: string;
|
||||
models: AgentModelOption[];
|
||||
};
|
||||
|
||||
export type AgentChatSession = {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
run_status?: AgentRunStatus;
|
||||
is_streaming?: boolean;
|
||||
messages?: unknown[];
|
||||
created_at?: number | string;
|
||||
updated_at?: number | string;
|
||||
is_title_manually_edited?: boolean;
|
||||
parent_session_id?: string;
|
||||
};
|
||||
|
||||
export type AgentChatSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
status?: string;
|
||||
parentSessionId?: string;
|
||||
isStreaming?: boolean;
|
||||
runStatus?: AgentRunStatus;
|
||||
};
|
||||
|
||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited: boolean;
|
||||
messages: unknown[];
|
||||
};
|
||||
|
||||
export type AgentSessionStreamDataEventType =
|
||||
| "token"
|
||||
| "progress"
|
||||
| "todo_update"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "question_request"
|
||||
| "question_response"
|
||||
| "tool_call"
|
||||
| "ui_envelope"
|
||||
| "session_title"
|
||||
| "done"
|
||||
| "error"
|
||||
| "auth_required";
|
||||
|
||||
export type AgentSessionStreamEvent =
|
||||
| {
|
||||
type: "state";
|
||||
sessionId: string;
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
runStatus?: AgentRunStatus;
|
||||
}
|
||||
| {
|
||||
type: AgentSessionStreamDataEventType;
|
||||
sessionId?: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
createSession: () => Promise<AgentChatSession>;
|
||||
listSessions: () => Promise<AgentChatSessionSummary[]>;
|
||||
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||
streamSession: (
|
||||
sessionId: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||
}
|
||||
) => Promise<void>;
|
||||
updateSessionTitle: (
|
||||
sessionId: string,
|
||||
title: string,
|
||||
options?: { isTitleManuallyEdited?: boolean }
|
||||
) => Promise<void>;
|
||||
deleteSession: (sessionId: string) => Promise<void>;
|
||||
getModels: () => Promise<AgentModelsResponse>;
|
||||
getUiRegistry: () => Promise<unknown>;
|
||||
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
|
||||
replyPermission: (
|
||||
requestId: string,
|
||||
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
|
||||
) => Promise<void>;
|
||||
replyQuestion: (
|
||||
requestId: string,
|
||||
options: { sessionId: string; answers: string[][] }
|
||||
) => Promise<void>;
|
||||
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
|
||||
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
|
||||
: ["", "http://127.0.0.1:8787"];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
|
||||
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
|
||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||
let activeBaseUrl = candidates[0] ?? "";
|
||||
|
||||
return {
|
||||
async createSession() {
|
||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
"/sessions"
|
||||
);
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return toLoadedSession(data);
|
||||
},
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
await readSessionSseStream(response, options.onEvent);
|
||||
},
|
||||
|
||||
async updateSessionTitle(sessionId, title, options) {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: normalizedTitle,
|
||||
is_title_manually_edited: options?.isTitleManuallyEdited
|
||||
})
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
`/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
},
|
||||
|
||||
async getModels() {
|
||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/models");
|
||||
return toModelsResponse(payload);
|
||||
},
|
||||
|
||||
async getUiRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/ui-registry");
|
||||
},
|
||||
|
||||
async resolveRenderRef(renderRef, sessionId) {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
return requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
async replyPermission(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: options.sessionId,
|
||||
reply: options.reply,
|
||||
message: options.message
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: options.sessionId,
|
||||
answers: options.answers
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async rejectQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: options.sessionId
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async abort(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/abort", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback<T>(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function fetchWithFallback(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
}
|
||||
|
||||
lastResponse = response;
|
||||
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
|
||||
await response.body?.cancel();
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResponse) {
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||
}
|
||||
|
||||
function shouldFallbackOnHttpStatus(status: number) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null | undefined): value is T {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
function toModelsResponse(value: unknown): AgentModelsResponse {
|
||||
if (!isRecord(value)) {
|
||||
return { defaultModel: "", models: [] };
|
||||
}
|
||||
|
||||
const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : [];
|
||||
const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? "";
|
||||
return {
|
||||
defaultModel,
|
||||
models
|
||||
};
|
||||
}
|
||||
|
||||
function toModelOption(value: unknown): AgentModelOption | null {
|
||||
if (!isRecord(value) || typeof value.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id;
|
||||
const description =
|
||||
typeof value.description === "string" && value.description.trim() ? value.description : label;
|
||||
const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle";
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
label,
|
||||
description,
|
||||
icon
|
||||
};
|
||||
}
|
||||
|
||||
function getResponseErrorMessage(data: unknown, status: number) {
|
||||
return isRecord(data) && typeof data.message === "string"
|
||||
? data.message
|
||||
: `Agent API failed with HTTP ${status}`;
|
||||
}
|
||||
|
||||
function toMillis(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function normalizeTitle(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "新对话";
|
||||
}
|
||||
|
||||
function readRunStatus(value: unknown): AgentRunStatus | undefined {
|
||||
return value === "running" || value === "completed" || value === "error" || value === "aborted"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: normalizeTitle(value.title),
|
||||
createdAt: toMillis(value.created_at),
|
||||
updatedAt: toMillis(value.updated_at),
|
||||
status: typeof value.status === "string" ? value.status : undefined,
|
||||
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
|
||||
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
|
||||
runStatus: readRunStatus(value.run_status)
|
||||
};
|
||||
}
|
||||
|
||||
function toLoadedSession(value: unknown): AgentLoadedChatSession {
|
||||
const summary = toSessionSummary(value);
|
||||
if (!summary || !isRecord(value)) {
|
||||
throw new Error("Invalid agent session payload");
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
|
||||
isTitleManuallyEdited:
|
||||
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
|
||||
messages: Array.isArray(value.messages) ? value.messages : []
|
||||
};
|
||||
}
|
||||
|
||||
async function readSessionSseStream(
|
||||
response: Response,
|
||||
onEvent: (event: AgentSessionStreamEvent) => void
|
||||
) {
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
|
||||
let delimiterIndex = buffer.indexOf("\n\n");
|
||||
while (delimiterIndex !== -1) {
|
||||
const rawEvent = buffer.slice(0, delimiterIndex);
|
||||
buffer = buffer.slice(delimiterIndex + 2);
|
||||
emitSessionSseEvent(rawEvent, onEvent);
|
||||
delimiterIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
emitSessionSseEvent(buffer, onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSessionSseEvent(
|
||||
rawEvent: string,
|
||||
onEvent: (event: AgentSessionStreamEvent) => void
|
||||
) {
|
||||
const lines = rawEvent.split(/\r?\n/);
|
||||
const eventType = lines
|
||||
.find((line) => line.startsWith("event:"))
|
||||
?.slice("event:".length)
|
||||
.trim();
|
||||
const dataText = lines
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart())
|
||||
.join("\n");
|
||||
|
||||
if (!eventType || !dataText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(dataText) as unknown;
|
||||
if (!isRecord(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "state") {
|
||||
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId,
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
isStreaming: data.is_streaming === true,
|
||||
runStatus: readRunStatus(data.run_status)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSessionStreamDataEventType(eventType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onEvent({
|
||||
type: eventType,
|
||||
sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
|
||||
return (
|
||||
value === "token" ||
|
||||
value === "progress" ||
|
||||
value === "todo_update" ||
|
||||
value === "permission_request" ||
|
||||
value === "permission_response" ||
|
||||
value === "question_request" ||
|
||||
value === "question_response" ||
|
||||
value === "tool_call" ||
|
||||
value === "ui_envelope" ||
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
value === "error" ||
|
||||
value === "auth_required"
|
||||
);
|
||||
}
|
||||
|
||||
function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) {
|
||||
const createdAtDiff = right.createdAt - left.createdAt;
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff;
|
||||
}
|
||||
|
||||
const updatedAtDiff = right.updatedAt - left.updatedAt;
|
||||
if (updatedAtDiff !== 0) {
|
||||
return updatedAtDiff;
|
||||
}
|
||||
|
||||
return right.id.localeCompare(left.id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient } from "./client";
|
||||
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
|
||||
|
||||
describe("Agent API SWR helpers", () => {
|
||||
it("loads bootstrap data with a normalized UI registry", async () => {
|
||||
const client = {
|
||||
getUiRegistry: vi.fn(async () => ({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: ["echarts-safe-subset"],
|
||||
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }],
|
||||
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||
})),
|
||||
getModels: vi.fn(async () => ({
|
||||
defaultModel: "model-a",
|
||||
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }]
|
||||
}))
|
||||
} as Pick<AgentApiClient, "getUiRegistry" | "getModels"> as AgentApiClient;
|
||||
|
||||
await expect(fetchAgentBootstrap(client)).resolves.toEqual({
|
||||
defaultModel: "model-a",
|
||||
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }],
|
||||
registry: {
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: ["echarts-safe-subset"],
|
||||
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }],
|
||||
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("loads session summaries through the shared client", async () => {
|
||||
const sessions = [
|
||||
{
|
||||
id: "session-1",
|
||||
title: "会话",
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
];
|
||||
const client = {
|
||||
listSessions: vi.fn(async () => sessions)
|
||||
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
|
||||
|
||||
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
AgentApiClient,
|
||||
AgentChatSessionSummary,
|
||||
AgentModelOption
|
||||
} from "./client";
|
||||
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
||||
|
||||
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
|
||||
export const agentSessionsKey = ["agent", "sessions"] as const;
|
||||
|
||||
export type AgentBootstrapData = {
|
||||
registry: UIRegistry | null;
|
||||
models: AgentModelOption[];
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
||||
const [rawRegistry, modelsResponse] = await Promise.all([
|
||||
client.getUiRegistry(),
|
||||
client.getModels()
|
||||
]);
|
||||
|
||||
return {
|
||||
registry: parseUiRegistry(rawRegistry),
|
||||
models: modelsResponse.models,
|
||||
defaultModel: modelsResponse.defaultModel
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
|
||||
return client.listSessions();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||
|
||||
describe("chart data normalization", () => {
|
||||
it("parses chart spec aliases", () => {
|
||||
expect(parseChartSpec({ title: "压力", chart_type: "bar", x_axis_name: "时间" })).toEqual({
|
||||
title: "压力",
|
||||
chartType: "bar",
|
||||
xAxisName: "时间",
|
||||
yAxisName: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes series with x_data and numeric strings", () => {
|
||||
expect(
|
||||
normalizeSafeChartData({
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "压力", data: ["1.2", 1.4] }]
|
||||
})
|
||||
).toEqual({
|
||||
xData: ["00:00", "01:00"],
|
||||
series: [{ name: "压力", data: [1.2, 1.4], type: undefined }]
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes raw point arrays into a default series", () => {
|
||||
expect(
|
||||
normalizeSafeChartData({
|
||||
series: [
|
||||
["A", 12],
|
||||
["B", "13"]
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
xData: ["A", "B"],
|
||||
series: [{ name: "数据", data: [12, 13], type: undefined }]
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes object points", () => {
|
||||
expect(pointToLabelValue({ time: "10:00", value: "2.4" }, "fallback")).toEqual({
|
||||
label: "10:00",
|
||||
value: 2.4
|
||||
});
|
||||
});
|
||||
|
||||
it("clips chart points to the safe limit", () => {
|
||||
const data = normalizeSafeChartData(
|
||||
{
|
||||
x_data: Array.from({ length: 30 }, (_, index) => String(index)),
|
||||
series: [{ name: "流量", data: Array.from({ length: 30 }, (_, index) => index) }]
|
||||
},
|
||||
5
|
||||
);
|
||||
|
||||
expect(data?.xData).toHaveLength(5);
|
||||
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
export type SafeChartSpec = {
|
||||
title?: string;
|
||||
chartType: "line" | "bar";
|
||||
xAxisName?: string;
|
||||
yAxisName?: string;
|
||||
};
|
||||
|
||||
export type SafeChartSeries = {
|
||||
name: string;
|
||||
data: number[];
|
||||
type?: "line" | "bar";
|
||||
};
|
||||
|
||||
export type SafeChartData = {
|
||||
xData: string[];
|
||||
series: SafeChartSeries[];
|
||||
};
|
||||
|
||||
type RawChartPoint = number | string | [unknown, unknown] | RawChartPointObject;
|
||||
|
||||
type RawChartPointObject = {
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
time?: unknown;
|
||||
timestamp?: unknown;
|
||||
label?: unknown;
|
||||
name?: unknown;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
type RawChartSeries = {
|
||||
name?: unknown;
|
||||
data?: unknown;
|
||||
points?: unknown;
|
||||
values?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
|
||||
export function parseChartSpec(value: unknown): SafeChartSpec {
|
||||
if (!isRecord(value)) {
|
||||
return { chartType: "line" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: stringValue(value.title),
|
||||
chartType: value.chart_type === "bar" || value.chartType === "bar" ? "bar" : "line",
|
||||
xAxisName: stringValue(value.x_axis_name ?? value.xAxisName),
|
||||
yAxisName: stringValue(value.y_axis_name ?? value.yAxisName)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChartData | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xData = normalizeXData(value.x_data ?? value.xData);
|
||||
const rawSeriesItems = normalizeRawSeriesItems(value.series);
|
||||
if (!rawSeriesItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSeries = rawSeriesItems
|
||||
.map((rawItem, seriesIndex): SafeChartSeries | null => {
|
||||
const item =
|
||||
isRecord(rawItem) && !isRawChartPoint(rawItem) ? rawItem : ({ data: rawItem } satisfies RawChartSeries);
|
||||
const rawData = item.data ?? item.points ?? item.values;
|
||||
if (!Array.isArray(rawData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelsFromPoints: string[] = [];
|
||||
const data = rawData
|
||||
.slice(0, maxPoints)
|
||||
.map((point, index) => {
|
||||
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
labelsFromPoints[index] = parsed.label;
|
||||
return parsed.value;
|
||||
})
|
||||
.filter((item): item is number => item !== null);
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!xData.length && labelsFromPoints.length) {
|
||||
xData.push(...labelsFromPoints);
|
||||
}
|
||||
|
||||
return {
|
||||
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
|
||||
data,
|
||||
type: item.type === "line" || item.type === "bar" ? item.type : undefined
|
||||
};
|
||||
})
|
||||
.filter((item): item is SafeChartSeries => Boolean(item));
|
||||
|
||||
const clippedXData = xData.slice(0, maxPoints);
|
||||
const clippedSeries = normalizedSeries.map((series) => ({
|
||||
...series,
|
||||
data: series.data.slice(0, clippedXData.length)
|
||||
}));
|
||||
|
||||
return clippedXData.length > 0 && clippedSeries.length > 0
|
||||
? {
|
||||
xData: clippedXData,
|
||||
series: clippedSeries
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function pointToLabelValue(point: RawChartPoint, fallbackLabel: string): { label: string; value: number } | null {
|
||||
const directValue = toFiniteNumber(point);
|
||||
if (directValue !== null) {
|
||||
return { label: fallbackLabel, value: directValue };
|
||||
}
|
||||
|
||||
if (Array.isArray(point)) {
|
||||
const value = toFiniteNumber(point[1]);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return { label: String(point[0] ?? fallbackLabel), value };
|
||||
}
|
||||
|
||||
if (isRecord(point)) {
|
||||
const value = toFiniteNumber(point.value ?? point.y);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const label = point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
||||
return { label: String(label), value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeXData(rawXData: unknown): string[] {
|
||||
return Array.isArray(rawXData) ? rawXData.map((item) => String(item ?? "")).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function normalizeRawSeriesItems(rawSeries: unknown): unknown[] {
|
||||
if (!Array.isArray(rawSeries)) {
|
||||
return isRecord(rawSeries) ? [rawSeries] : [];
|
||||
}
|
||||
|
||||
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) ? [{ name: "数据", data: rawSeries }] : rawSeries;
|
||||
}
|
||||
|
||||
function isRawChartPoint(item: unknown): boolean {
|
||||
if (toFiniteNumber(item) !== null) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
||||
}
|
||||
if (isRecord(item)) {
|
||||
return item.data === undefined && item.points === undefined && item.values === undefined && toFiniteNumber(item.value ?? item.y) !== null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
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 state = getCollapsedAgentState(statusLabel, personaState);
|
||||
|
||||
return (
|
||||
<aside className={cn("agent-rail-enter pointer-events-auto w-[188px] p-2", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<div className="agent-panel-control agent-rail-item rounded-2xl p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentPersona
|
||||
className="h-10 w-10"
|
||||
fallbackClassName="h-10 w-10"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-slate-950">HydroAgent</p>
|
||||
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", state.dotClassName)} />
|
||||
<span className="truncate" title={statusLabel}>{state.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开 Agent 面板"
|
||||
title="展开 Agent 面板"
|
||||
onClick={onExpand}
|
||||
className="agent-panel-primary-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
|
||||
>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) {
|
||||
if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) {
|
||||
return {
|
||||
label: "离线",
|
||||
dotClassName: "bg-red-500"
|
||||
};
|
||||
}
|
||||
|
||||
if (personaState === "listening") {
|
||||
return {
|
||||
label: "待确认",
|
||||
dotClassName: "bg-orange-500"
|
||||
};
|
||||
}
|
||||
|
||||
if (personaState === "thinking") {
|
||||
return {
|
||||
label: "处理中",
|
||||
dotClassName: "bg-blue-500"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "在线",
|
||||
dotClassName: "bg-emerald-500"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bolt,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
Gauge,
|
||||
History,
|
||||
Plus,
|
||||
SendHorizontal,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton
|
||||
} from "@/shared/ai-elements/conversation";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { Message, MessageContent } from "@/shared/ai-elements/message";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea
|
||||
} from "@/shared/ai-elements/prompt-input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants,
|
||||
agentPanelSectionVariants
|
||||
} from "./agent-motion";
|
||||
import { AgentHistoryPanel } from "./agent-history-panel";
|
||||
import { AgentMessageDetails } from "./agent-message-details";
|
||||
import { AgentOperationalBrief } from "./agent-operational-brief";
|
||||
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
||||
import { StreamingTokenResponse } from "./streaming-token-response";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderState,
|
||||
AgentUiResult
|
||||
} from "../types";
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
collapsing?: boolean;
|
||||
personaState?: PersonaState;
|
||||
sessionTitle?: string;
|
||||
sessionHistory?: AgentChatSessionSummary[];
|
||||
sessionHistoryLoading?: boolean;
|
||||
activeSessionId?: string | null;
|
||||
statusLabel?: string;
|
||||
streaming?: boolean;
|
||||
messages?: AgentChatMessage[];
|
||||
streamRenderState?: AgentStreamRenderState;
|
||||
modelOptions?: AgentModelOption[];
|
||||
selectedModel?: string;
|
||||
uiResults?: AgentUiResult[];
|
||||
onCollapse: () => void;
|
||||
onRefreshHistory?: () => Promise<void> | void;
|
||||
onStartNewSession?: () => Promise<void> | void;
|
||||
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
|
||||
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
|
||||
onSelectModel?: (model: string) => void;
|
||||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||||
onStopPrompt?: () => void;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AgentCommandPanel({
|
||||
collapsing = false,
|
||||
personaState,
|
||||
sessionTitle = "新对话",
|
||||
sessionHistory = [],
|
||||
sessionHistoryLoading = false,
|
||||
activeSessionId,
|
||||
statusLabel = "Agent 后端连接中",
|
||||
streaming = false,
|
||||
messages = [],
|
||||
streamRenderState = {},
|
||||
modelOptions = [],
|
||||
selectedModel = "",
|
||||
uiResults = [],
|
||||
onCollapse,
|
||||
onRefreshHistory,
|
||||
onStartNewSession,
|
||||
onLoadHistorySession,
|
||||
onRenameHistorySession,
|
||||
onDeleteHistorySession,
|
||||
onSelectModel,
|
||||
onSubmitPrompt,
|
||||
onStopPrompt,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: AgentCommandPanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const trimmedPrompt = prompt.trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (historyOpen) {
|
||||
void Promise.resolve(onRefreshHistory?.());
|
||||
}
|
||||
}, [historyOpen, onRefreshHistory]);
|
||||
|
||||
const submitPrompt = (nextPrompt: string) => {
|
||||
if (!nextPrompt || streaming) {
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
setScrollRequestId((requestId) => requestId + 1);
|
||||
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
|
||||
setPrompt(nextPrompt);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<aside
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<div className="agent-panel-band border-b border-white/70">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
|
||||
<AgentPersona
|
||||
className="h-[72px] w-[72px]"
|
||||
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">对话主题</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-white/75 px-2 text-xs font-semibold text-slate-600">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<ConversationContent className="gap-4 p-3">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
<BackendMessageList
|
||||
key="messages"
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
streamRenderState={streamRenderState}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="w-full"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState statusLabel={statusLabel} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AgentUiEnvelopeRenderer results={uiResults} />
|
||||
</ConversationContent>
|
||||
<AgentConversationScrollManager
|
||||
activeSessionId={activeSessionId}
|
||||
messages={messages}
|
||||
scrollRequestId={scrollRequestId}
|
||||
streaming={streaming}
|
||||
uiResults={uiResults}
|
||||
/>
|
||||
<ConversationScrollButton className="bottom-3" />
|
||||
</Conversation>
|
||||
</AgentContent>
|
||||
|
||||
<div className="agent-panel-band border-t border-white/70 p-3">
|
||||
<PromptInput
|
||||
className="agent-panel-control rounded-2xl shadow-sm"
|
||||
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>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-500" title={statusLabel}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
onValueChange={onSelectModel}
|
||||
/>
|
||||
<PromptInputSubmit
|
||||
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
className="agent-panel-primary-icon"
|
||||
disabled={!streaming && !trimmedPrompt}
|
||||
status={streaming ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
>
|
||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</Agent>
|
||||
</aside>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentConversationScrollSnapshot = {
|
||||
activeSessionId?: string | null;
|
||||
lastUserMessageId: string | null;
|
||||
scrollRequestId: number;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
function AgentConversationScrollManager({
|
||||
activeSessionId,
|
||||
messages,
|
||||
scrollRequestId,
|
||||
streaming,
|
||||
uiResults
|
||||
}: {
|
||||
activeSessionId?: string | null;
|
||||
messages: AgentChatMessage[];
|
||||
scrollRequestId: number;
|
||||
streaming: boolean;
|
||||
uiResults: AgentUiResult[];
|
||||
}) {
|
||||
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext();
|
||||
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
||||
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom);
|
||||
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
||||
() => ({
|
||||
activeSessionId,
|
||||
lastUserMessageId: getLastUserMessageId(messages),
|
||||
scrollRequestId,
|
||||
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
|
||||
}),
|
||||
[activeSessionId, messages, scrollRequestId, streaming, uiResults]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSnapshot = previousSnapshotRef.current;
|
||||
previousSnapshotRef.current = snapshot;
|
||||
|
||||
const forceScroll =
|
||||
!previousSnapshot ||
|
||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||
const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature;
|
||||
|
||||
if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = window.requestAnimationFrame(() => {
|
||||
void scrollToBottom({
|
||||
animation: "instant",
|
||||
ignoreEscapes: forceScroll,
|
||||
wait: !forceScroll
|
||||
});
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(animationFrameId);
|
||||
}, [scrollToBottom, snapshot, wasPinnedToBottom]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "user") {
|
||||
return messages[index].id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAgentConversationScrollSignature(
|
||||
messages: AgentChatMessage[],
|
||||
streaming: boolean,
|
||||
uiResults: AgentUiResult[]
|
||||
) {
|
||||
return [
|
||||
streaming ? "streaming" : "idle",
|
||||
messages.map(createAgentMessageScrollSignature).join("|"),
|
||||
uiResults.map((result) => result.id).join("|")
|
||||
].join("#");
|
||||
}
|
||||
|
||||
function createAgentMessageScrollSignature(message: AgentChatMessage) {
|
||||
return [
|
||||
message.id,
|
||||
message.role,
|
||||
message.content.length,
|
||||
message.progress?.map((item) => `${item.id}:${item.status}:${item.title.length}:${item.detail?.length ?? 0}`).join(",") ?? "",
|
||||
message.todos?.todos.map((todo) => `${todo.id}:${todo.status}:${todo.content.length}`).join(",") ?? "",
|
||||
message.permissions?.map((permission) => `${permission.requestId}:${permission.status}:${permission.error?.length ?? 0}`).join(",") ?? "",
|
||||
message.questions?.map((question) => `${question.requestId}:${question.status}:${question.error?.length ?? 0}`).join(",") ?? ""
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function AgentModelSelect({
|
||||
models,
|
||||
value,
|
||||
onValueChange
|
||||
}: {
|
||||
models: AgentModelOption[];
|
||||
value: string;
|
||||
onValueChange?: (model: string) => void;
|
||||
}) {
|
||||
const selectedModel = models.find((model) => model.id === value) ?? models[0];
|
||||
|
||||
if (!models.length) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
|
||||
模型
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 w-[126px] shrink-0 justify-between rounded-md border-transparent bg-transparent px-2 text-xs text-slate-600 shadow-none hover:bg-white/80"
|
||||
disabled={!onValueChange}
|
||||
aria-label="选择 Agent 模型"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ModelIcon model={selectedModel} size={13} />
|
||||
<span className="truncate">{selectedModel?.label ?? "模型"}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={13} className="shrink-0 opacity-50" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="agent-panel-control w-72 rounded-xl p-2 shadow-lg"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
|
||||
Agent 模型
|
||||
</DropdownMenuLabel>
|
||||
<div className="space-y-1">
|
||||
{models.map((model) => {
|
||||
const selected = model.id === selectedModel?.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
className={cn(
|
||||
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||
selected && "border-violet-200 bg-violet-50/80 focus:bg-violet-50"
|
||||
)}
|
||||
onSelect={() => onValueChange?.(model.id)}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-white text-slate-600 shadow-sm">
|
||||
<ModelIcon model={model} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
|
||||
</span>
|
||||
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function getAgentConnectionLabel(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
|
||||
}
|
||||
|
||||
function getAgentConnectionClassName(statusLabel: string) {
|
||||
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
|
||||
}
|
||||
|
||||
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
||||
return model?.icon === "bolt" ? (
|
||||
<Bolt size={size} className="text-amber-500" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles size={size} className="text-violet-500" aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState({ statusLabel }: { statusLabel: string }) {
|
||||
return (
|
||||
<Message from="assistant" className="max-w-full">
|
||||
<MessageContent className="agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-600 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge size={15} className="text-blue-600" aria-hidden="true" />
|
||||
<p className="font-semibold text-slate-900">等待调度任务</p>
|
||||
</div>
|
||||
<p className="mt-2">后端会话已接入,新的任务会在这里形成证据、计划、预览和确认记录。</p>
|
||||
<p className="mt-1 text-xs text-slate-500">{statusLabel}</p>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendMessageList({
|
||||
messages,
|
||||
streaming,
|
||||
streamRenderState,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
messages: AgentChatMessage[];
|
||||
streaming: boolean;
|
||||
streamRenderState: AgentStreamRenderState;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
layout
|
||||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<Message
|
||||
from={message.role}
|
||||
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
||||
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{message.content ? (
|
||||
message.role === "assistant" ? (
|
||||
<StreamingTokenResponse
|
||||
className="leading-6"
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)
|
||||
) : streaming && index === messages.length - 1 ? (
|
||||
<span className="text-slate-500">正在生成...</span>
|
||||
) : null}
|
||||
{message.role === "assistant" ? (
|
||||
<AgentMessageDetails
|
||||
message={message}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
|
||||
type AgentHistoryPanelProps = {
|
||||
activeSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingSessionId: string | null;
|
||||
sessions: AgentChatSessionSummary[];
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AgentHistoryPanel({
|
||||
activeSessionId,
|
||||
loading,
|
||||
loadingSessionId,
|
||||
sessions,
|
||||
onRefresh,
|
||||
onSelectSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
}: AgentHistoryPanelProps) {
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
||||
|
||||
const beginRename = (session: AgentChatSessionSummary) => {
|
||||
setEditingSessionId(session.id);
|
||||
setDraftTitle(session.title);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
const saveRename = (session: AgentChatSessionSummary) => {
|
||||
const normalizedTitle = draftTitle.trim();
|
||||
if (!normalizedTitle || normalizedTitle === session.title) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSessionId(session.id);
|
||||
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
||||
.then(cancelRename)
|
||||
.finally(() => setSavingSessionId(null));
|
||||
};
|
||||
|
||||
const confirmDelete = (sessionId: string) => {
|
||||
setDeletingSessionId(sessionId);
|
||||
void Promise.resolve(onDeleteSession?.(sessionId))
|
||||
.then(() => setConfirmingDeleteSessionId(null))
|
||||
.finally(() => setDeletingSessionId(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
正在加载
|
||||
</motion.div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
const active = session.id === activeSessionId;
|
||||
const itemLoading = loadingSessionId === session.id;
|
||||
const editing = editingSessionId === session.id;
|
||||
const saving = savingSessionId === session.id;
|
||||
const deleting = deletingSessionId === session.id;
|
||||
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
删除后无法从历史记录中恢复
|
||||
</span>
|
||||
</motion.div>
|
||||
) : editing ? (
|
||||
<motion.form
|
||||
key="rename"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveRename(session);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 rounded-lg bg-white text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
{formatSessionUpdatedAt(session.updatedAt)}
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-1">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
setConfirmingDeleteSessionId(session.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSessionUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
ListChecks,
|
||||
LockKeyhole,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants,
|
||||
agentPanelSectionVariants
|
||||
} from "./agent-motion";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentQuestionInfo,
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate
|
||||
} from "../types";
|
||||
|
||||
type ProgressStatus = AgentChatProgress["status"];
|
||||
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
|
||||
type PermissionStatus = AgentPermissionRequest["status"];
|
||||
type QuestionStatus = AgentQuestionRequest["status"];
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
running: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
},
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-100"
|
||||
},
|
||||
error: {
|
||||
label: "失败",
|
||||
className: "bg-red-100 text-red-700",
|
||||
dotClassName: "border-red-500 bg-red-100"
|
||||
}
|
||||
};
|
||||
|
||||
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-500"
|
||||
},
|
||||
in_progress: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
},
|
||||
pending: {
|
||||
label: "待办",
|
||||
className: "bg-slate-100 text-slate-600",
|
||||
dotClassName: "border-slate-300 bg-white"
|
||||
},
|
||||
cancelled: {
|
||||
label: "取消",
|
||||
className: "bg-slate-200 text-slate-500",
|
||||
dotClassName: "border-slate-300 bg-slate-200"
|
||||
}
|
||||
};
|
||||
|
||||
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
|
||||
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
|
||||
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
|
||||
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
|
||||
};
|
||||
|
||||
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
|
||||
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
|
||||
};
|
||||
|
||||
export function AgentMessageDetails({
|
||||
message,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
message: AgentChatMessage;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
const hasDetails = Boolean(
|
||||
message.progress?.length ||
|
||||
message.todos?.todos.length ||
|
||||
message.permissions?.length ||
|
||||
message.questions?.length
|
||||
);
|
||||
|
||||
if (!hasDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{message.progress?.length ? (
|
||||
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<ProgressList progress={message.progress} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
{message.todos ? (
|
||||
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<TodoCard todoUpdate={message.todos} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
{message.permissions?.length ? (
|
||||
<motion.div key="permissions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
{message.questions?.length ? (
|
||||
<motion.div key="questions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<QuestionList
|
||||
questions={message.questions}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentNestedBlock({
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<Icon size={14} className={iconClassName} aria-hidden="true" />
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{progress.slice(-6).map((item) => (
|
||||
<motion.li
|
||||
key={item.id}
|
||||
layout
|
||||
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 rounded-full border",
|
||||
progressStatusMeta[item.status].dotClassName
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-slate-800" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail ? (
|
||||
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ol>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||
<motion.li
|
||||
key={todo.id}
|
||||
layout
|
||||
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
||||
todoStatusMeta[todo.status].dotClassName
|
||||
)}
|
||||
>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
||||
{todoStatusMeta[todo.status].label}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ol>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionList({
|
||||
permissions,
|
||||
onReplyPermission
|
||||
}: {
|
||||
permissions: AgentPermissionRequest[];
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
<AnimatedDetailItem key={permission.requestId}>
|
||||
<PermissionRequestCard
|
||||
permission={permission}
|
||||
onReplyPermission={onReplyPermission}
|
||||
/>
|
||||
</AnimatedDetailItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionRequestCard({
|
||||
permission,
|
||||
onReplyPermission
|
||||
}: {
|
||||
permission: AgentPermissionRequest;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
const actionable = permission.status === "pending" || permission.status === "error";
|
||||
const submitting = permission.status === "submitting";
|
||||
const canApproveAlways = permission.always.length > 0;
|
||||
const disabled = !onReplyPermission || submitting;
|
||||
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
|
||||
|
||||
const reply = (nextReply: AgentPermissionReply) => {
|
||||
if (!onReplyPermission || submitting) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onReplyPermission(permission, nextReply));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
||||
{getPermissionTitle(permission)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
|
||||
{target}
|
||||
</p>
|
||||
{permission.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||
{permissionStatusMeta[permission.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="permission-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("once")}
|
||||
>
|
||||
<CheckCircle2 size={13} aria-hidden="true" />
|
||||
允许一次
|
||||
</Button>
|
||||
{canApproveAlways ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("always")}
|
||||
>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
始终允许
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("reject")}
|
||||
>
|
||||
<XCircle size={13} aria-hidden="true" />
|
||||
拒绝
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
questions,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
questions: AgentQuestionRequest[];
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息">
|
||||
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{questions.slice(-3).map((request) => (
|
||||
<AnimatedDetailItem key={request.requestId}>
|
||||
<QuestionRequestCard
|
||||
request={request}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
</AnimatedDetailItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionRequestCard({
|
||||
request,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
request: AgentQuestionRequest;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
|
||||
const actionable = request.status === "pending" || request.status === "error";
|
||||
const submitting = request.status === "submitting";
|
||||
const disabled = submitting || !onReplyQuestion;
|
||||
const answers = normalizeDraftAnswers(draftAnswers);
|
||||
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
|
||||
|
||||
const submit = () => {
|
||||
if (!onReplyQuestion || !canSubmit) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onReplyQuestion(request, answers));
|
||||
};
|
||||
|
||||
const reject = () => {
|
||||
if (!onRejectQuestion || submitting) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onRejectQuestion(request));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
||||
{questionStatusMeta[request.status].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{request.questions.map((question, questionIndex) => (
|
||||
<QuestionInput
|
||||
key={`${request.requestId}-${questionIndex}`}
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||
disabled={!actionable || submitting}
|
||||
onChange={(nextValue) =>
|
||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{request.error ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
|
||||
) : null}
|
||||
{request.answers?.length ? (
|
||||
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="question-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
<CheckCircle2 size={13} aria-hidden="true" />
|
||||
提交回答
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
disabled={submitting || !onRejectQuestion}
|
||||
onClick={reject}
|
||||
>
|
||||
<XCircle size={13} aria-hidden="true" />
|
||||
跳过
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QuestionDraftAnswer = {
|
||||
selected: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
function QuestionInput({
|
||||
question,
|
||||
questionIndex,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
question: AgentQuestionInfo;
|
||||
questionIndex: number;
|
||||
value: QuestionDraftAnswer;
|
||||
disabled: boolean;
|
||||
onChange: (value: QuestionDraftAnswer) => void;
|
||||
}) {
|
||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||
const showCustomInput = question.custom || question.options.length === 0;
|
||||
|
||||
const toggleOption = (label: string, checked: boolean) => {
|
||||
if (question.multiple) {
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [label] : []
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||
{question.options.length ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option) => {
|
||||
const checked = value.selected.includes(option.label);
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-slate-600"
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold text-slate-700">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="block leading-4 text-slate-500">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||
return request.questions.map((question, index) => ({
|
||||
selected: request.answers?.[index] ?? [],
|
||||
custom: ""
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||
return draftAnswers.map((answer) => {
|
||||
const custom = answer.custom.trim();
|
||||
return custom ? [...answer.selected, custom] : answer.selected;
|
||||
});
|
||||
}
|
||||
|
||||
function getPermissionTitle(permission: AgentPermissionRequest) {
|
||||
if (permission.permission === "bash") return "执行终端命令";
|
||||
if (permission.permission === "edit") return "修改文件内容";
|
||||
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||
return permission.permission || "工具权限请求";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import type { Variants } from "motion/react";
|
||||
|
||||
export const AGENT_MOTION_DURATION_MS = 180;
|
||||
|
||||
export const agentEase = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
export const agentPanelItemVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 6,
|
||||
scale: 0.985
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -4,
|
||||
scale: 0.99,
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentPanelSectionVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
y: -4
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
height: "auto",
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
y: -4,
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentPanelListVariants: Variants = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.025
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentLayoutTransition = {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Crosshair,
|
||||
MapPinned,
|
||||
Radio,
|
||||
Route
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentChatMessage
|
||||
} from "../types";
|
||||
|
||||
export function AgentOperationalBrief({
|
||||
messages,
|
||||
statusLabel,
|
||||
streaming,
|
||||
onSubmitCommand
|
||||
}: {
|
||||
messages: AgentChatMessage[];
|
||||
statusLabel: string;
|
||||
streaming: boolean;
|
||||
onSubmitCommand: (prompt: string) => void;
|
||||
}) {
|
||||
const brief = createOperationalBrief(messages, statusLabel, streaming);
|
||||
|
||||
return (
|
||||
<div className="agent-panel-control rounded-2xl p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-1 text-xs font-semibold text-violet-700">
|
||||
Agent 推断
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||
>
|
||||
<MapPinned size={14} aria-hidden="true" />
|
||||
预览影响
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
<Crosshair size={14} aria-hidden="true" />
|
||||
检查证据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
||||
const latestProgress = progressItems.at(-1);
|
||||
const activeTodo =
|
||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||
const pendingConfirmations =
|
||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
|
||||
|
||||
return {
|
||||
task,
|
||||
evidence,
|
||||
plan,
|
||||
confirmation,
|
||||
stateLabel,
|
||||
statusDotClassName,
|
||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"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", 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";
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
|
||||
type AgentUiEnvelopeRendererProps = {
|
||||
results: AgentUiResult[];
|
||||
};
|
||||
|
||||
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{results.map((result) => (
|
||||
<motion.div
|
||||
key={result.id}
|
||||
layout
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AgentUiResultCard result={result} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
const { envelope } = result;
|
||||
|
||||
return (
|
||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
||||
{getEnvelopeIcon(envelope)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
||||
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
|
||||
agent-ui@1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
|
||||
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
|
||||
{envelope.kind === "map_action" ? null : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data);
|
||||
|
||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||
{spec.title ?? "Agent 图表"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{data.series.slice(0, 3).map((series, index) => (
|
||||
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
|
||||
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
|
||||
{series.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SafeChartSvg spec={spec} data={data} />
|
||||
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
||||
const width = 360;
|
||||
const height = 190;
|
||||
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const values = data.series.flatMap((series) => series.data);
|
||||
const min = Math.min(0, ...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const xFor = (index: number) =>
|
||||
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
||||
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
||||
const ticks = createTicks(min, max);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
||||
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
||||
{ticks.map((tick) => {
|
||||
const y = yFor(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
||||
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
||||
{formatChartNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
{data.xData.map((label, index) => (
|
||||
<text
|
||||
key={`${label}-${index}`}
|
||||
x={xFor(index)}
|
||||
y={height - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{shortLabel(label)}
|
||||
</text>
|
||||
))}
|
||||
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
||||
(series.type ?? spec.chartType) === "bar" ? (
|
||||
<BarSeries
|
||||
key={series.name}
|
||||
series={series}
|
||||
seriesIndex={seriesIndex}
|
||||
seriesCount={Math.min(data.series.length, 4)}
|
||||
xFor={xFor}
|
||||
yFor={yFor}
|
||||
baselineY={yFor(0)}
|
||||
/>
|
||||
) : (
|
||||
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
xFor,
|
||||
yFor
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
}) {
|
||||
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
||||
const stroke = chartStroke(seriesIndex);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="agent-chart-line"
|
||||
/>
|
||||
{series.data.map((value, index) => (
|
||||
<circle
|
||||
key={`${series.name}-${index}`}
|
||||
cx={xFor(index)}
|
||||
cy={yFor(value)}
|
||||
r="2.8"
|
||||
fill={stroke}
|
||||
className="agent-chart-point"
|
||||
style={{ animationDelay: `${80 + index * 18}ms` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function BarSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
seriesCount,
|
||||
xFor,
|
||||
yFor,
|
||||
baselineY
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
seriesCount: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
baselineY: number;
|
||||
}) {
|
||||
const fill = chartStroke(seriesIndex);
|
||||
const barWidth = Math.max(5, 22 / seriesCount);
|
||||
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{series.data.map((value, index) => {
|
||||
const y = yFor(value);
|
||||
return (
|
||||
<rect
|
||||
key={`${series.name}-${index}`}
|
||||
x={xFor(index) - barWidth / 2 + offset}
|
||||
y={Math.min(y, baselineY)}
|
||||
width={barWidth}
|
||||
height={Math.max(1, Math.abs(baselineY - y))}
|
||||
rx="2"
|
||||
fill={fill}
|
||||
className="agent-chart-bar"
|
||||
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisteredComponentEnvelope({
|
||||
envelope
|
||||
}: {
|
||||
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
|
||||
}) {
|
||||
if (envelope.component === "HistoryPanel") {
|
||||
return (
|
||||
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
||||
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
|
||||
</TrustedPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.component === "ScadaPanel") {
|
||||
return (
|
||||
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
||||
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
|
||||
</TrustedPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
|
||||
}
|
||||
|
||||
function TrustedPanelShell({
|
||||
icon,
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
|
||||
const rows = safePropRows(props, labels);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-slate-600">已打开可信面板,暂无可展示参数。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2">
|
||||
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safePropRows(props: unknown, labels: Record<string, string>) {
|
||||
if (!isRecord(props)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(labels).flatMap(([key, label]) => {
|
||||
const value = props[key];
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ label, value: String(value) }];
|
||||
});
|
||||
}
|
||||
|
||||
function getEnvelopeIcon(envelope: UIEnvelope) {
|
||||
if (envelope.kind === "chart") {
|
||||
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
|
||||
<BarChart3 size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<LineChart size={16} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
return <Database size={16} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <Activity size={16} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||
if (envelope.kind === "chart") {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
return spec.title ?? "Agent 图表";
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
return envelope.component;
|
||||
}
|
||||
|
||||
return envelope.action;
|
||||
}
|
||||
|
||||
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
||||
if (surface === "chat_inline") return "聊天内联展示";
|
||||
if (surface === "side_panel") return "侧栏展示";
|
||||
if (surface === "canvas") return "画布展示";
|
||||
return "地图叠加";
|
||||
}
|
||||
|
||||
function createTicks(min: number, max: number) {
|
||||
const range = max - min || 1;
|
||||
return [max, min + range * 0.5, min];
|
||||
}
|
||||
|
||||
function chartStroke(index: number) {
|
||||
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
||||
}
|
||||
|
||||
function chartColorClass(index: number) {
|
||||
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
|
||||
}
|
||||
|
||||
function formatChartNumber(value: number) {
|
||||
if (Math.abs(value) >= 100) {
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 10) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function shortLabel(value: string) {
|
||||
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const DEFAULT_STREAMING_TOKEN_LOCALE = "zh";
|
||||
export const STREAMING_TOKEN_BLOCK_SIZE = 4;
|
||||
|
||||
const FALLBACK_CHUNK_SIZE = 2;
|
||||
|
||||
export function splitStreamingText(text: string, locale = DEFAULT_STREAMING_TOKEN_LOCALE) {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(locale, { granularity: "word" })
|
||||
: null;
|
||||
|
||||
if (segmenter) {
|
||||
return Array.from(segmenter.segment(text), (item) => item.segment).filter(Boolean);
|
||||
}
|
||||
|
||||
return splitStreamingTextFallback(text);
|
||||
}
|
||||
|
||||
export function splitStreamingTextFallback(text: string) {
|
||||
const codePoints = Array.from(text);
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < codePoints.length; index += FALLBACK_CHUNK_SIZE) {
|
||||
chunks.push(codePoints.slice(index, index + FALLBACK_CHUNK_SIZE).join(""));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function takeStreamingTokenBlock(pending: string[], blockSize = STREAMING_TOKEN_BLOCK_SIZE) {
|
||||
return pending.splice(0, blockSize).join("");
|
||||
}
|
||||
|
||||
export type StreamingMarkdownBufferState = {
|
||||
content: string;
|
||||
displayContent: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
type FenceMarker = {
|
||||
char: "`" | "~";
|
||||
length: number;
|
||||
};
|
||||
|
||||
const FENCE_START_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
|
||||
const HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]+\S/;
|
||||
const PARTIAL_HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]*$/;
|
||||
const LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\S/;
|
||||
const PARTIAL_LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]*$/;
|
||||
const BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]?\S/;
|
||||
const PARTIAL_BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]*$/;
|
||||
const HORIZONTAL_RULE_PATTERN = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})[ \t]*(?:\r?\n|$)/;
|
||||
const TABLE_SEPARATOR_PATTERN = /^ {0,3}\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
|
||||
const POSSIBLE_TABLE_HEADER_PATTERN = /^ {0,3}\|.*\||^ {0,3}\S.*\|.*\|/;
|
||||
const LINE_START_PATTERN = /(?:^|\n)(?= {0,3}(?:#{1,6}|[-+*]|\d{1,9}[.)]|>|`{3,}|~{3,}|\|))/g;
|
||||
|
||||
function readFenceStart(line: string): FenceMarker | null {
|
||||
const match = FENCE_START_PATTERN.exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = match[1];
|
||||
return {
|
||||
char: marker[0] as FenceMarker["char"],
|
||||
length: marker.length,
|
||||
};
|
||||
}
|
||||
|
||||
function readLineEnd(content: string) {
|
||||
const newlineIndex = content.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
return content.length;
|
||||
}
|
||||
return newlineIndex + 1;
|
||||
}
|
||||
|
||||
function isTableLine(line: string) {
|
||||
return line.includes("|") && line.trim().length > 0;
|
||||
}
|
||||
|
||||
function isTableSeparatorLine(line: string) {
|
||||
return TABLE_SEPARATOR_PATTERN.test(line);
|
||||
}
|
||||
|
||||
type MarkdownOpenerMatch = {
|
||||
chunk: string;
|
||||
};
|
||||
|
||||
export function createStreamingMarkdownBufferState(
|
||||
content = "",
|
||||
displayContent = "",
|
||||
locale = DEFAULT_STREAMING_TOKEN_LOCALE
|
||||
): StreamingMarkdownBufferState {
|
||||
return { content, displayContent, locale };
|
||||
}
|
||||
|
||||
export function resolveStreamingMarkdownDisplayContent(
|
||||
content: string,
|
||||
displayContent: string,
|
||||
streamDone: boolean
|
||||
) {
|
||||
if (streamDone) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content.startsWith(displayContent) ? displayContent : "";
|
||||
}
|
||||
|
||||
function isLineStart(content: string, offset: number) {
|
||||
return offset === 0 || content[offset - 1] === "\n";
|
||||
}
|
||||
|
||||
function takeMarkdownTableOpener(content: string): MarkdownOpenerMatch | null {
|
||||
const headerEnd = readLineEnd(content);
|
||||
const headerLine = content.slice(0, headerEnd).replace(/\r?\n$/, "");
|
||||
|
||||
if (!POSSIBLE_TABLE_HEADER_PATTERN.test(headerLine)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (headerEnd >= content.length && !content.endsWith("\n")) {
|
||||
return { chunk: "" };
|
||||
}
|
||||
|
||||
const afterHeader = content.slice(headerEnd);
|
||||
const separatorEnd = readLineEnd(afterHeader);
|
||||
const separatorLine = afterHeader.slice(0, separatorEnd).replace(/\r?\n$/, "");
|
||||
|
||||
if (!separatorLine) {
|
||||
return { chunk: "" };
|
||||
}
|
||||
|
||||
if (!isTableSeparatorLine(separatorLine)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chunk: content.slice(0, headerEnd + separatorEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function isStreamingTableOpen(displayContent: string) {
|
||||
const lines = displayContent.replace(/\r\n/g, "\n").split("\n");
|
||||
|
||||
while (lines.at(-1) === "") {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
const blockLines: string[] = [];
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
break;
|
||||
}
|
||||
if (!isTableLine(line)) {
|
||||
break;
|
||||
}
|
||||
blockLines.unshift(line);
|
||||
}
|
||||
|
||||
return (
|
||||
blockLines.length >= 2 &&
|
||||
blockLines.every(isTableLine) &&
|
||||
blockLines.some(isTableSeparatorLine)
|
||||
);
|
||||
}
|
||||
|
||||
function takeMarkdownOpener(
|
||||
content: string,
|
||||
{ allowTable = true }: { allowTable?: boolean } = {}
|
||||
): MarkdownOpenerMatch | null {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineEnd = readLineEnd(content);
|
||||
const firstLine = content.slice(0, lineEnd);
|
||||
const lineWithoutEnding = firstLine.replace(/\r?\n$/, "");
|
||||
const fence = readFenceStart(lineWithoutEnding);
|
||||
|
||||
if (fence) {
|
||||
return firstLine.endsWith("\n") ? { chunk: firstLine } : { chunk: "" };
|
||||
}
|
||||
|
||||
const tableOpener = allowTable ? takeMarkdownTableOpener(content) : null;
|
||||
if (tableOpener) {
|
||||
return tableOpener;
|
||||
}
|
||||
|
||||
const firstNonSyntaxIndex = (pattern: RegExp) => {
|
||||
const match = pattern.exec(content);
|
||||
return match ? match[0].length : 0;
|
||||
};
|
||||
|
||||
const headingLength = firstNonSyntaxIndex(HEADING_OPENER_PATTERN);
|
||||
if (headingLength > 0) {
|
||||
return { chunk: content.slice(0, headingLength) };
|
||||
}
|
||||
if (PARTIAL_HEADING_OPENER_PATTERN.test(lineWithoutEnding)) {
|
||||
return { chunk: "" };
|
||||
}
|
||||
|
||||
const listLength = firstNonSyntaxIndex(LIST_OPENER_PATTERN);
|
||||
if (listLength > 0) {
|
||||
return { chunk: content.slice(0, listLength) };
|
||||
}
|
||||
if (PARTIAL_LIST_OPENER_PATTERN.test(lineWithoutEnding)) {
|
||||
return { chunk: "" };
|
||||
}
|
||||
|
||||
const blockquoteLength = firstNonSyntaxIndex(BLOCKQUOTE_OPENER_PATTERN);
|
||||
if (blockquoteLength > 0) {
|
||||
return { chunk: content.slice(0, blockquoteLength) };
|
||||
}
|
||||
if (PARTIAL_BLOCKQUOTE_OPENER_PATTERN.test(lineWithoutEnding)) {
|
||||
return { chunk: "" };
|
||||
}
|
||||
|
||||
const ruleMatch = HORIZONTAL_RULE_PATTERN.exec(content);
|
||||
if (ruleMatch) {
|
||||
return { chunk: ruleMatch[0] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isMarkdownOpenerBoundary(content: string, offset = 0) {
|
||||
if (!isLineStart(content, offset)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const opener = takeMarkdownOpener(content.slice(offset));
|
||||
return Boolean(opener?.chunk);
|
||||
}
|
||||
|
||||
function findNextPotentialMarkdownOpenerIndex(content: string) {
|
||||
LINE_START_PATTERN.lastIndex = 0;
|
||||
|
||||
for (const match of content.matchAll(LINE_START_PATTERN)) {
|
||||
const index = match.index + (match[0] === "\n" ? 1 : 0);
|
||||
if (index > 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function takeSegmentedMarkdownChunk(
|
||||
content: string,
|
||||
locale: string,
|
||||
blockSize = STREAMING_TOKEN_BLOCK_SIZE
|
||||
) {
|
||||
const pendingSegments = splitStreamingText(content, locale);
|
||||
const candidate = takeStreamingTokenBlock(pendingSegments, blockSize);
|
||||
|
||||
if (!candidate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const nextOpenerIndex = findNextPotentialMarkdownOpenerIndex(candidate);
|
||||
return nextOpenerIndex > 0 ? candidate.slice(0, nextOpenerIndex) : candidate;
|
||||
}
|
||||
|
||||
export function takeNextStreamingMarkdownChunk(
|
||||
state: StreamingMarkdownBufferState,
|
||||
blockSize = STREAMING_TOKEN_BLOCK_SIZE
|
||||
) {
|
||||
const displayContent = state.content.startsWith(state.displayContent)
|
||||
? state.displayContent
|
||||
: "";
|
||||
const pendingContent = state.content.slice(displayContent.length);
|
||||
|
||||
if (!pendingContent) {
|
||||
return {
|
||||
chunk: "",
|
||||
state: { ...state, displayContent },
|
||||
};
|
||||
}
|
||||
|
||||
const opener = isLineStart(state.content, displayContent.length)
|
||||
? takeMarkdownOpener(pendingContent, {
|
||||
allowTable: !isStreamingTableOpen(displayContent),
|
||||
})
|
||||
: null;
|
||||
const chunk = opener
|
||||
? opener.chunk
|
||||
: takeSegmentedMarkdownChunk(pendingContent, state.locale, blockSize);
|
||||
const nextDisplayContent = `${displayContent}${chunk}`;
|
||||
|
||||
return {
|
||||
chunk,
|
||||
state: {
|
||||
...state,
|
||||
displayContent: nextDisplayContent,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
import {
|
||||
createStreamingMarkdownBufferState,
|
||||
resolveStreamingMarkdownDisplayContent,
|
||||
takeNextStreamingMarkdownChunk,
|
||||
} from "./streaming-token-response-state";
|
||||
|
||||
type StreamingTokenResponseProps = {
|
||||
content: string;
|
||||
streaming: boolean;
|
||||
streamDone?: boolean;
|
||||
className?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
const STREAMING_MARKDOWN_FRAME_MS = 45;
|
||||
|
||||
export function StreamingTokenResponse({
|
||||
content,
|
||||
streaming,
|
||||
streamDone = !streaming,
|
||||
className,
|
||||
messageId,
|
||||
}: StreamingTokenResponseProps) {
|
||||
const [displayContent, setDisplayContent] = useState(() =>
|
||||
streamDone ? content : ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayContent((currentDisplayContent) =>
|
||||
resolveStreamingMarkdownDisplayContent(
|
||||
content,
|
||||
currentDisplayContent,
|
||||
streamDone
|
||||
)
|
||||
);
|
||||
}, [content, messageId, streamDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamDone || !streaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showNextChunk = () => {
|
||||
setDisplayContent((currentDisplayContent) => {
|
||||
const { chunk, state } = takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState(content, currentDisplayContent)
|
||||
);
|
||||
|
||||
if (chunk || state.displayContent !== currentDisplayContent) {
|
||||
return state.displayContent;
|
||||
}
|
||||
|
||||
return currentDisplayContent;
|
||||
});
|
||||
};
|
||||
|
||||
showNextChunk();
|
||||
const timer = window.setInterval(showNextChunk, STREAMING_MARKDOWN_FRAME_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [content, streamDone, streaming]);
|
||||
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={cn("agent-streaming-response", className)}
|
||||
isAnimating={streaming}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown={true}
|
||||
>
|
||||
{displayContent}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||
export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
|
||||
export {
|
||||
applyPermissionResponse,
|
||||
applyQuestionResponse,
|
||||
cancelRunningTodos,
|
||||
completeRunningProgress,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
toTodoUpdate,
|
||||
updateMessageById,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestion
|
||||
} from "./session-state";
|
||||
export type {
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentInteractionToolRef,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionStatus,
|
||||
AgentQuestionInfo,
|
||||
AgentQuestionOption,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderChunk,
|
||||
AgentStreamRenderMessageState,
|
||||
AgentStreamRenderState,
|
||||
AgentTodoItem,
|
||||
AgentTodoUpdate,
|
||||
AgentUiResult
|
||||
} from "./types";
|
||||
export type {
|
||||
SafeChartData,
|
||||
SafeChartSeries,
|
||||
SafeChartSpec
|
||||
} from "./chart-data";
|
||||
export type {
|
||||
AssistantMessageSections,
|
||||
ContentSegment,
|
||||
ParsedToolContent,
|
||||
ToolCall
|
||||
} from "./message-sections";
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
|
||||
|
||||
describe("parseAssistantMessageSections", () => {
|
||||
it("returns plain assistant content when there is no thought block", () => {
|
||||
expect(parseAssistantMessageSections("直接回答")).toEqual({
|
||||
answer: "直接回答",
|
||||
thought: null,
|
||||
thoughtComplete: false
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a completed thought block and keeps the final answer visible", () => {
|
||||
expect(parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答")).toEqual({
|
||||
answer: "最终回答",
|
||||
thought: "先分析需求",
|
||||
thoughtComplete: true
|
||||
});
|
||||
});
|
||||
|
||||
it("supports streaming thought content before the closing tag arrives", () => {
|
||||
expect(parseAssistantMessageSections("准备中...\n<think>继续推理中")).toEqual({
|
||||
answer: "准备中...",
|
||||
thought: "继续推理中",
|
||||
thoughtComplete: false
|
||||
});
|
||||
});
|
||||
|
||||
it("merges multiple thought blocks into a single collapsed section", () => {
|
||||
expect(
|
||||
parseAssistantMessageSections(
|
||||
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾"
|
||||
)
|
||||
).toEqual({
|
||||
answer: "答案开头\n\n答案结尾",
|
||||
thought: "第一段思考\n\n第二段思考",
|
||||
thoughtComplete: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseContentWithToolCalls", () => {
|
||||
it("returns a single text segment when there are no tool calls", () => {
|
||||
const result = parseContentWithToolCalls("普通文本回答");
|
||||
|
||||
expect(result.segments).toEqual([{ type: "text", content: "普通文本回答" }]);
|
||||
expect(result.toolCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses a complete tool_call block", () => {
|
||||
const content =
|
||||
'分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
|
||||
const result = parseContentWithToolCalls(content);
|
||||
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls[0].tool).toBe("locate_junctions");
|
||||
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
|
||||
expect(result.segments).toHaveLength(3);
|
||||
expect(result.segments[0]).toEqual({ type: "text", content: "分析完成。" });
|
||||
expect(result.segments[1]).toMatchObject({
|
||||
type: "tool_call",
|
||||
toolCall: { tool: "locate_junctions" }
|
||||
});
|
||||
expect(result.segments[2]).toEqual({ type: "text", content: "以上是结果。" });
|
||||
});
|
||||
|
||||
it("parses multiple tool_call blocks", () => {
|
||||
const content =
|
||||
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
|
||||
const result = parseContentWithToolCalls(content);
|
||||
|
||||
expect(result.toolCalls).toHaveLength(2);
|
||||
expect(result.toolCalls[0].tool).toBe("locate_pipes");
|
||||
expect(result.toolCalls[1].tool).toBe("chart");
|
||||
expect(result.segments).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("detects an unclosed tool_call tag as pending while streaming", () => {
|
||||
const result = parseContentWithToolCalls('正在分析...\n<tool_call>{"tool":"locate_no');
|
||||
|
||||
expect(result.segments).toEqual([
|
||||
{ type: "text", content: "正在分析..." },
|
||||
{ type: "tool_call_pending" }
|
||||
]);
|
||||
expect(result.toolCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("strips partial opening tags during streaming", () => {
|
||||
const result = parseContentWithToolCalls("正在分析...\n<tool_c");
|
||||
|
||||
expect(result.segments).toEqual([{ type: "text", content: "正在分析..." }]);
|
||||
});
|
||||
|
||||
it("handles malformed JSON gracefully", () => {
|
||||
const result = parseContentWithToolCalls("前文\n<tool_call>{invalid json}</tool_call>\n后文");
|
||||
|
||||
expect(result.toolCalls).toHaveLength(0);
|
||||
expect(result.segments.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns empty segments for empty content", () => {
|
||||
const result = parseContentWithToolCalls("");
|
||||
|
||||
expect(result.segments).toHaveLength(0);
|
||||
expect(result.toolCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
export type AssistantMessageSections = {
|
||||
answer: string;
|
||||
thought: string | null;
|
||||
thoughtComplete: boolean;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
tool: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "tool_call"; toolCall: ToolCall }
|
||||
| { type: "tool_call_pending" };
|
||||
|
||||
export type ParsedToolContent = {
|
||||
segments: ContentSegment[];
|
||||
toolCalls: ToolCall[];
|
||||
};
|
||||
|
||||
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
|
||||
const THINK_OPEN_TAG = "<think>";
|
||||
const THINK_CLOSE_TAG = "</think>";
|
||||
|
||||
export function parseAssistantMessageSections(content: string): AssistantMessageSections {
|
||||
if (!content) {
|
||||
return { answer: "", thought: null, thoughtComplete: false };
|
||||
}
|
||||
|
||||
const thoughtParts: string[] = [];
|
||||
let answer = content;
|
||||
|
||||
answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => {
|
||||
const trimmedThought = thoughtContent.trim();
|
||||
if (trimmedThought) {
|
||||
thoughtParts.push(trimmedThought);
|
||||
}
|
||||
|
||||
return "\n";
|
||||
});
|
||||
|
||||
const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG);
|
||||
const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG);
|
||||
const hasUnclosedThought = lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex;
|
||||
|
||||
if (hasUnclosedThought) {
|
||||
const streamingThought = answer.slice(lastOpenIndex + THINK_OPEN_TAG.length).trim();
|
||||
|
||||
if (streamingThought) {
|
||||
thoughtParts.push(streamingThought);
|
||||
}
|
||||
|
||||
answer = answer.slice(0, lastOpenIndex);
|
||||
}
|
||||
|
||||
const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim();
|
||||
const normalizedThought = thoughtParts.join("\n\n").trim();
|
||||
|
||||
return {
|
||||
answer: normalizedAnswer,
|
||||
thought: normalizedThought || null,
|
||||
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought
|
||||
};
|
||||
}
|
||||
|
||||
const TOOL_CALL_OPEN_TAG = "<tool_call>";
|
||||
const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
|
||||
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
|
||||
|
||||
export function parseContentWithToolCalls(content: string): ParsedToolContent {
|
||||
if (!content) {
|
||||
return { segments: [], toolCalls: [] };
|
||||
}
|
||||
|
||||
const segments: ContentSegment[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let lastIndex = 0;
|
||||
let toolCallIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = TOOL_CALL_PATTERN.exec(content)) !== null) {
|
||||
const textBefore = content.slice(lastIndex, match.index);
|
||||
if (textBefore.trim()) {
|
||||
segments.push({ type: "text", content: textBefore.trim() });
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1].trim()) as {
|
||||
tool?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
const toolCall: ToolCall = {
|
||||
id: `tc-${toolCallIndex++}`,
|
||||
tool: parsed.tool ?? "unknown",
|
||||
params: parsed.params ?? {}
|
||||
};
|
||||
segments.push({ type: "tool_call", toolCall });
|
||||
toolCalls.push(toolCall);
|
||||
} catch {
|
||||
segments.push({ type: "text", content: match[0] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const remaining = content.slice(lastIndex);
|
||||
const unclosedIndex = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
|
||||
|
||||
if (unclosedIndex !== -1) {
|
||||
const textBefore = remaining.slice(0, unclosedIndex);
|
||||
if (textBefore.trim()) {
|
||||
segments.push({ type: "text", content: textBefore.trim() });
|
||||
}
|
||||
segments.push({ type: "tool_call_pending" });
|
||||
} else {
|
||||
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
|
||||
if (cleaned) {
|
||||
segments.push({ type: "text", content: cleaned });
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
segments.push({ type: "text", content });
|
||||
}
|
||||
|
||||
return { segments, toolCalls };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPermissionResponse,
|
||||
applyQuestionResponse,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
toTodoUpdate,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestion
|
||||
} from "./session-state";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
describe("agent session state", () => {
|
||||
it("upserts progress events by id", () => {
|
||||
const first = upsertProgress(undefined, {
|
||||
id: "tool-1",
|
||||
phase: "tool",
|
||||
status: "running",
|
||||
title: "调用工具",
|
||||
elapsed_ms: 20
|
||||
});
|
||||
const next = upsertProgress(first, {
|
||||
id: "tool-1",
|
||||
phase: "tool",
|
||||
status: "completed",
|
||||
title: "工具完成",
|
||||
duration_ms: 40
|
||||
});
|
||||
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
|
||||
});
|
||||
|
||||
it("normalizes todo updates", () => {
|
||||
expect(
|
||||
toTodoUpdate({
|
||||
session_id: "session-1",
|
||||
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
|
||||
created_at: 123
|
||||
})
|
||||
).toEqual({
|
||||
sessionId: "session-1",
|
||||
messageId: undefined,
|
||||
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
|
||||
createdAt: 123
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks permission request and response", () => {
|
||||
const permissions = upsertPermission(undefined, {
|
||||
session_id: "session-1",
|
||||
request_id: "permission-1",
|
||||
permission: "bash",
|
||||
patterns: ["pnpm test"],
|
||||
always: [],
|
||||
created_at: 100
|
||||
});
|
||||
const next = applyPermissionResponse(permissions, {
|
||||
request_id: "permission-1",
|
||||
reply: "once"
|
||||
});
|
||||
|
||||
expect(next?.[0]).toMatchObject({
|
||||
requestId: "permission-1",
|
||||
status: "approved_once"
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes question requests by tool call id", () => {
|
||||
const questions = upsertQuestion(undefined, {
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 100
|
||||
});
|
||||
const next = upsertQuestion(questions, {
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 120
|
||||
});
|
||||
const answered = applyQuestionResponse(next, {
|
||||
request_id: "question-1",
|
||||
answers: [["东部"]]
|
||||
});
|
||||
|
||||
expect(next).toHaveLength(1);
|
||||
expect(answered?.[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
status: "answered",
|
||||
answers: [["东部"]]
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes open assistant state after abort", () => {
|
||||
const message: AgentChatMessage = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
progress: [{ id: "run", phase: "run", status: "running", title: "处理中", startedAt: Date.now() }],
|
||||
permissions: [
|
||||
{
|
||||
requestId: "permission-1",
|
||||
sessionId: "session-1",
|
||||
permission: "bash",
|
||||
patterns: [],
|
||||
always: [],
|
||||
createdAt: 100,
|
||||
status: "pending"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const next = finalizeAssistantMessageAfterAbort(message);
|
||||
|
||||
expect(next.content).toBe("请求已中止。");
|
||||
expect(next.progress?.[0].status).toBe("completed");
|
||||
expect(next.permissions?.[0].status).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentInteractionToolRef,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionStatus,
|
||||
AgentQuestionInfo,
|
||||
AgentQuestionRequest,
|
||||
AgentTodoItem,
|
||||
AgentTodoUpdate
|
||||
} from "./types";
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
export function updateMessageById(
|
||||
messages: AgentChatMessage[],
|
||||
messageId: string,
|
||||
updater: (message: AgentChatMessage) => AgentChatMessage
|
||||
) {
|
||||
return messages.map((message) => (message.id === messageId ? updater(message) : message));
|
||||
}
|
||||
|
||||
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
|
||||
const payload = asRecord(data);
|
||||
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
|
||||
const now = Date.now();
|
||||
const index = progress?.findIndex((item) => item.id === id) ?? -1;
|
||||
const existing = index >= 0 ? progress?.[index] : undefined;
|
||||
const status = readProgressStatus(payload.status);
|
||||
const startedAt = readNumber(payload.started_at) ?? readNumber(payload.startedAt) ?? existing?.startedAt;
|
||||
const endedAt = readNumber(payload.ended_at) ?? readNumber(payload.endedAt);
|
||||
const elapsedMs = readNumber(payload.elapsed_ms) ?? readNumber(payload.elapsedMs);
|
||||
const durationMs = readNumber(payload.duration_ms) ?? readNumber(payload.durationMs);
|
||||
const nextItem: AgentChatProgress = {
|
||||
id,
|
||||
phase: readString(payload.phase) ?? "progress",
|
||||
status,
|
||||
title: readString(payload.title) ?? "正在处理",
|
||||
detail: readString(payload.detail),
|
||||
startedAt,
|
||||
endedAt,
|
||||
elapsedMs: status === "running" ? elapsedMs : undefined,
|
||||
elapsedSnapshotAt: status === "running" && elapsedMs !== undefined ? now : undefined,
|
||||
durationMs: status === "running" ? undefined : durationMs
|
||||
};
|
||||
const next = [...(progress ?? [])];
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = nextItem;
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
|
||||
return progress?.map((item) => {
|
||||
if (item.status !== "running") {
|
||||
return item;
|
||||
}
|
||||
|
||||
const endedAt = Date.now();
|
||||
return {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
endedAt,
|
||||
elapsedMs: undefined,
|
||||
elapsedSnapshotAt: undefined,
|
||||
durationMs:
|
||||
item.durationMs ??
|
||||
(item.startedAt !== undefined ? Math.max(0, endedAt - item.startedAt) : item.elapsedMs)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toTodoUpdate(data: unknown): AgentTodoUpdate | null {
|
||||
const payload = asRecord(data);
|
||||
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||
const rawTodos = Array.isArray(payload.todos) ? payload.todos : [];
|
||||
|
||||
if (!sessionId || rawTodos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
messageId: readString(payload.message_id) ?? readString(payload.messageId),
|
||||
todos: rawTodos.map(toTodoItem).filter((item): item is AgentTodoItem => Boolean(item)),
|
||||
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export function cancelRunningTodos(todoUpdate: AgentTodoUpdate | undefined) {
|
||||
return todoUpdate
|
||||
? {
|
||||
...todoUpdate,
|
||||
todos: todoUpdate.todos.map((todo) =>
|
||||
todo.status === "pending" || todo.status === "in_progress"
|
||||
? {
|
||||
...todo,
|
||||
status: "cancelled" as const,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: todo
|
||||
)
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function upsertPermission(
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
data: unknown
|
||||
) {
|
||||
const payload = asRecord(data);
|
||||
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||
|
||||
if (!requestId || !sessionId) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const nextItem: AgentPermissionRequest = {
|
||||
requestId,
|
||||
sessionId,
|
||||
permission: readString(payload.permission) ?? "unknown",
|
||||
patterns: readStringArray(payload.patterns),
|
||||
target: readString(payload.target),
|
||||
always: readStringArray(payload.always),
|
||||
tool: toToolRef(payload.tool),
|
||||
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
|
||||
status: "pending"
|
||||
};
|
||||
const next = [...(permissions ?? [])];
|
||||
const index = next.findIndex((permission) => permission.requestId === requestId);
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyPermissionResponse(
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
data: unknown
|
||||
) {
|
||||
const payload = asRecord(data);
|
||||
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||
if (!requestId) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
return permissions?.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? {
|
||||
...permission,
|
||||
status: toPermissionStatus(readString(payload.reply)),
|
||||
repliedAt: Date.now(),
|
||||
error: undefined
|
||||
}
|
||||
: permission
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, data: unknown) {
|
||||
const payload = asRecord(data);
|
||||
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
|
||||
const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
|
||||
|
||||
if (!requestId || !sessionId || rawQuestions.length === 0) {
|
||||
return questions;
|
||||
}
|
||||
|
||||
const nextItem: AgentQuestionRequest = {
|
||||
requestId,
|
||||
sessionId,
|
||||
questions: rawQuestions.map(toQuestionInfo).filter((item): item is AgentQuestionInfo => Boolean(item)),
|
||||
tool: toToolRef(payload.tool),
|
||||
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
|
||||
status: "pending"
|
||||
};
|
||||
const next = [...(questions ?? [])];
|
||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefined, data: unknown) {
|
||||
const payload = asRecord(data);
|
||||
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
|
||||
if (!requestId) {
|
||||
return questions;
|
||||
}
|
||||
|
||||
const answers = Array.isArray(payload.answers)
|
||||
? payload.answers.map((answer) => (Array.isArray(answer) ? answer.map(String) : []))
|
||||
: undefined;
|
||||
const rejected = payload.rejected === true;
|
||||
|
||||
return questions?.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: rejected ? ("rejected" as const) : ("answered" as const),
|
||||
answers: answers ?? question.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined
|
||||
}
|
||||
: question
|
||||
);
|
||||
}
|
||||
|
||||
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
|
||||
const progress = completeRunningProgress(message.progress);
|
||||
const todos = cancelRunningTodos(message.todos);
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: message.content || "请求已中止。",
|
||||
progress,
|
||||
todos,
|
||||
permissions: message.permissions?.map((permission) =>
|
||||
permission.status === "pending" || permission.status === "submitting" || permission.status === "error"
|
||||
? {
|
||||
...permission,
|
||||
status: "aborted" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined
|
||||
}
|
||||
: permission
|
||||
),
|
||||
questions: message.questions?.map((question) =>
|
||||
question.status === "pending" || question.status === "submitting" || question.status === "error"
|
||||
? {
|
||||
...question,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined
|
||||
}
|
||||
: question
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
|
||||
if (reply === "always") {
|
||||
return "approved_always";
|
||||
}
|
||||
if (reply === "once") {
|
||||
return "approved_once";
|
||||
}
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
function isSameQuestion(left: AgentQuestionRequest, right: AgentQuestionRequest) {
|
||||
if (left.requestId === right.requestId) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(left.tool?.callID && right.tool?.callID && left.tool.callID === right.tool.callID);
|
||||
}
|
||||
|
||||
function toQuestionInfo(value: unknown): AgentQuestionInfo | null {
|
||||
const payload = asRecord(value);
|
||||
const question = readString(payload.question);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
header: readString(payload.header) ?? "",
|
||||
question,
|
||||
options: Array.isArray(payload.options)
|
||||
? payload.options
|
||||
.map((option) => {
|
||||
const item = asRecord(option);
|
||||
const label = readString(item.label);
|
||||
return label
|
||||
? {
|
||||
label,
|
||||
description: readString(item.description) ?? ""
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((item): item is { label: string; description: string } => Boolean(item))
|
||||
: [],
|
||||
multiple: typeof payload.multiple === "boolean" ? payload.multiple : undefined,
|
||||
custom: typeof payload.custom === "boolean" ? payload.custom : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function toTodoItem(value: unknown): AgentTodoItem | null {
|
||||
const payload = asRecord(value);
|
||||
const id = readString(payload.id);
|
||||
const content = readString(payload.content);
|
||||
const status = readTodoStatus(payload.status);
|
||||
|
||||
if (!id || !content || !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
status,
|
||||
priority: readTodoPriority(payload.priority),
|
||||
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt),
|
||||
updatedAt: readNumber(payload.updated_at) ?? readNumber(payload.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
|
||||
const payload = asRecord(value);
|
||||
const messageID = readString(payload.messageID);
|
||||
const callID = readString(payload.callID);
|
||||
return messageID && callID ? { messageID, callID } : undefined;
|
||||
}
|
||||
|
||||
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
|
||||
return value === "completed" || value === "error" ? value : "running";
|
||||
}
|
||||
|
||||
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
|
||||
if (value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readTodoPriority(value: unknown): AgentTodoItem["priority"] | undefined {
|
||||
if (value === "low" || value === "medium" || value === "high") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): RecordValue {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as RecordValue)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
createStreamingMarkdownBufferState,
|
||||
isMarkdownOpenerBoundary,
|
||||
resolveStreamingMarkdownDisplayContent,
|
||||
splitStreamingText,
|
||||
splitStreamingTextFallback,
|
||||
takeNextStreamingMarkdownChunk,
|
||||
} from "./components/streaming-token-response-state";
|
||||
|
||||
describe("streaming token response state", () => {
|
||||
it("splits streaming text without dropping content", () => {
|
||||
const content = "管网压力异常需要继续分析";
|
||||
const chunks = splitStreamingText(content, "zh");
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks.join("")).toBe(content);
|
||||
});
|
||||
|
||||
it("uses bounded code point chunks for fallback segmentation", () => {
|
||||
expect(splitStreamingTextFallback("管网压力")).toEqual(["管网", "压力"]);
|
||||
});
|
||||
|
||||
it("emits continuous Chinese text without waiting for markdown block closure", () => {
|
||||
let state = createStreamingMarkdownBufferState("正在分析管网压力异常需要继续分析");
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const next = takeNextStreamingMarkdownChunk(state);
|
||||
state = next.state;
|
||||
if (!next.chunk) {
|
||||
break;
|
||||
}
|
||||
chunks.push(next.chunk);
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks.join("")).toBe("正在分析管网压力异常需要继续分析");
|
||||
});
|
||||
|
||||
it("flushes a heading opener atomically", () => {
|
||||
const next = takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("### 压力分析")
|
||||
);
|
||||
|
||||
expect(next.chunk).toBe("### 压");
|
||||
expect(isMarkdownOpenerBoundary("### 压力分析")).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for a heading opener to include text", () => {
|
||||
const next = takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("### ")
|
||||
);
|
||||
|
||||
expect(next.chunk).toBe("");
|
||||
expect(next.state.displayContent).toBe("");
|
||||
});
|
||||
|
||||
it("flushes list and quote openers atomically", () => {
|
||||
expect(
|
||||
takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("- 节点 A")
|
||||
).chunk
|
||||
).toBe("- 节");
|
||||
expect(
|
||||
takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("> 注意压力")
|
||||
).chunk
|
||||
).toBe("> 注");
|
||||
});
|
||||
|
||||
it("flushes code and mermaid fence openers as complete lines", () => {
|
||||
expect(
|
||||
takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("```ts\nconst pressure = 1;")
|
||||
).chunk
|
||||
).toBe("```ts\n");
|
||||
expect(
|
||||
takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("```mermaid\ngraph TD")
|
||||
).chunk
|
||||
).toBe("```mermaid\n");
|
||||
});
|
||||
|
||||
it("waits for fenced code openers to reach the line ending", () => {
|
||||
const next = takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState("```ts")
|
||||
);
|
||||
|
||||
expect(next.chunk).toBe("");
|
||||
expect(next.state.displayContent).toBe("");
|
||||
});
|
||||
|
||||
it("flushes table header and separator rows atomically", () => {
|
||||
const content = "| 节点 | 压力 |\n| --- | --- |\n| J1 | 0.32 |\n\n后续";
|
||||
|
||||
expect(
|
||||
takeNextStreamingMarkdownChunk(
|
||||
createStreamingMarkdownBufferState(content)
|
||||
).chunk
|
||||
).toBe("| 节点 | 压力 |\n| --- | --- |\n");
|
||||
});
|
||||
|
||||
it("grows table rows after the table opener is visible", () => {
|
||||
const tableOpener = "| 节点 | 压力 |\n| --- | --- |\n";
|
||||
const state = createStreamingMarkdownBufferState(
|
||||
`${tableOpener}| J1 | 0.32 |`,
|
||||
tableOpener
|
||||
);
|
||||
|
||||
expect(takeNextStreamingMarkdownChunk(state).chunk).not.toBe("");
|
||||
});
|
||||
|
||||
it("flushes all display content after streaming is done", () => {
|
||||
const content = "前文\n\n```ts\nconst pressure = 1;";
|
||||
|
||||
expect(resolveStreamingMarkdownDisplayContent(content, "前文", true)).toBe(content);
|
||||
});
|
||||
|
||||
it("StreamingTokenResponse does not use closed-block markdown gating", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(source).not.toContain("splitStreamingMarkdownContent");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export type AgentChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
progress?: AgentChatProgress[];
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
todos?: AgentTodoUpdate;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderChunk = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderMessageState = {
|
||||
chunks: AgentStreamRenderChunk[];
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
|
||||
|
||||
export type AgentChatProgress = {
|
||||
id: string;
|
||||
phase: string;
|
||||
status: "running" | "completed" | "error";
|
||||
title: string;
|
||||
detail?: string;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
elapsedMs?: number;
|
||||
elapsedSnapshotAt?: number;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type AgentPermissionStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "approved_once"
|
||||
| "approved_always"
|
||||
| "rejected"
|
||||
| "aborted"
|
||||
| "error";
|
||||
|
||||
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||
|
||||
export type AgentModelOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: "bolt" | "sparkle";
|
||||
};
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
target?: string;
|
||||
always: string[];
|
||||
tool?: AgentInteractionToolRef;
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: AgentPermissionStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentQuestionOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type AgentQuestionInfo = {
|
||||
header: string;
|
||||
question: string;
|
||||
options: AgentQuestionOption[];
|
||||
multiple?: boolean;
|
||||
custom?: boolean;
|
||||
};
|
||||
|
||||
export type AgentQuestionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
questions: AgentQuestionInfo[];
|
||||
tool?: AgentInteractionToolRef;
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: "pending" | "submitting" | "answered" | "rejected" | "error";
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentTodoItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
priority?: "low" | "medium" | "high";
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export type AgentTodoUpdate = {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
todos: AgentTodoItem[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type AgentInteractionToolRef = {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
|
||||
export type AgentUiResult = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
envelope: import("./ui-envelope").UIEnvelope;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
||||
export { toTrustedMapAction, type TrustedMapAction } from "./map-actions";
|
||||
export {
|
||||
isUiEnvelopeAllowed,
|
||||
parseUiEnvelope,
|
||||
parseUiEnvelopePayload,
|
||||
parseUiRegistry
|
||||
} from "./validator";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toTrustedMapAction } from "./map-actions";
|
||||
|
||||
describe("toTrustedMapAction", () => {
|
||||
it("rejects zoom_to_map actions without a valid center", () => {
|
||||
expect(toTrustedMapAction("zoom_to_map", { zoom: 14 })).toBeNull();
|
||||
expect(toTrustedMapAction("zoom_to_map", { center: ["east", 39.1] })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsupported map actions", () => {
|
||||
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades malformed apply_layer_style params to safe undefined fields", () => {
|
||||
expect(
|
||||
toTrustedMapAction("apply_layer_style", {
|
||||
layer_group_id: 123,
|
||||
layer_id: [],
|
||||
visible: "true"
|
||||
})
|
||||
).toEqual({
|
||||
type: "apply_layer_style",
|
||||
layerGroupId: undefined,
|
||||
layerId: undefined,
|
||||
visible: undefined,
|
||||
fallbackText: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts valid zoom_to_map coordinates", () => {
|
||||
expect(toTrustedMapAction("zoom_to_map", { lng: 117.2, lat: 39.1, zoom: 13 })).toEqual({
|
||||
type: "zoom_to_map",
|
||||
center: [117.2, 39.1],
|
||||
zoom: 13,
|
||||
fallbackText: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy zoom coordinate aliases and numeric strings", () => {
|
||||
expect(toTrustedMapAction("zoom_to_map", { coordinate: ["117.2", "39.1"], zoom: "13" })).toEqual({
|
||||
type: "zoom_to_map",
|
||||
center: [117.2, 39.1],
|
||||
zoom: 13,
|
||||
fallbackText: undefined
|
||||
});
|
||||
expect(toTrustedMapAction("zoom_to_map", { x: "117.2", y: "39.1" })).toMatchObject({
|
||||
type: "zoom_to_map",
|
||||
center: [117.2, 39.1]
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy locate tool actions", () => {
|
||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||
type: "locate_features",
|
||||
featureIds: ["P1", "P2"],
|
||||
layer: "geo_pipes_mat",
|
||||
fallbackText: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes numeric locate ids", () => {
|
||||
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||
type: "locate_features",
|
||||
featureIds: ["42"],
|
||||
layer: "geo_junctions_mat"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
export type TrustedMapAction =
|
||||
| {
|
||||
type: "locate_features";
|
||||
featureIds: string[];
|
||||
layer?: string;
|
||||
fallbackText?: string;
|
||||
}
|
||||
| {
|
||||
type: "zoom_to_map";
|
||||
center: [number, number];
|
||||
zoom?: number;
|
||||
fallbackText?: string;
|
||||
}
|
||||
| {
|
||||
type: "apply_layer_style";
|
||||
layerGroupId?: string;
|
||||
layerId?: string;
|
||||
visible?: boolean;
|
||||
fallbackText?: string;
|
||||
}
|
||||
| {
|
||||
type: "render_junctions";
|
||||
renderRef: string;
|
||||
fallbackText?: string;
|
||||
};
|
||||
|
||||
export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null {
|
||||
if (!isRecord(params)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
||||
const featureIds = readLocateIds(params);
|
||||
return {
|
||||
type: "locate_features",
|
||||
featureIds,
|
||||
layer:
|
||||
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
|
||||
LEGACY_LOCATE_ACTION_LAYERS[action],
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "zoom_to_map") {
|
||||
const center = parseCenter(params);
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "zoom_to_map",
|
||||
center,
|
||||
zoom: numberValue(params.zoom),
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "apply_layer_style") {
|
||||
return {
|
||||
type: "apply_layer_style",
|
||||
layerGroupId: stringValue(params.layer_group_id ?? params.layerGroupId),
|
||||
layerId: stringValue(params.layer_id ?? params.layerId),
|
||||
visible: booleanValue(params.visible),
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "render_junctions") {
|
||||
const renderRef = stringValue(params.render_ref ?? params.renderRef);
|
||||
if (!renderRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "render_junctions",
|
||||
renderRef,
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCenter(params: Record<string, unknown>): [number, number] | null {
|
||||
if (Array.isArray(params.center) && params.center.length >= 2) {
|
||||
const lng = numberValue(params.center[0]);
|
||||
const lat = numberValue(params.center[1]);
|
||||
return lng === undefined || lat === undefined ? null : [lng, lat];
|
||||
}
|
||||
|
||||
const rawCoordinate = params.coordinate ?? params.coordinates;
|
||||
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
|
||||
const lng = numberValue(rawCoordinate[0]);
|
||||
const lat = numberValue(rawCoordinate[1]);
|
||||
return lng === undefined || lat === undefined ? null : [lng, lat];
|
||||
}
|
||||
|
||||
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
|
||||
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
|
||||
return lng === undefined || lat === undefined ? null : [lng, lat];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown) {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||
locate_junctions: "geo_junctions_mat",
|
||||
locate_pipes: "geo_pipes_mat",
|
||||
locate_valves: "geo_valves",
|
||||
locate_reservoirs: "geo_reservoirs",
|
||||
locate_pumps: "geo_pumps",
|
||||
locate_tanks: "geo_tanks"
|
||||
};
|
||||
|
||||
const LOCATE_ID_PARAM_KEYS = [
|
||||
"feature_ids",
|
||||
"feature_id",
|
||||
"featureIds",
|
||||
"featureId",
|
||||
"ids",
|
||||
"id",
|
||||
"node_ids",
|
||||
"node_id",
|
||||
"junction_ids",
|
||||
"junction_id",
|
||||
"pipe_ids",
|
||||
"pipe_id",
|
||||
"valve_ids",
|
||||
"valve_id",
|
||||
"reservoir_ids",
|
||||
"reservoir_id",
|
||||
"pump_ids",
|
||||
"pump_id",
|
||||
"tank_ids",
|
||||
"tank_id"
|
||||
] as const;
|
||||
|
||||
function readLocateIds(params: Record<string, unknown>) {
|
||||
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||
const value = params[key];
|
||||
const ids = normalizeIds(value);
|
||||
if (ids.length > 0) {
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIds(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value)
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay";
|
||||
|
||||
export type UIEnvelope =
|
||||
| {
|
||||
kind: "registered_component";
|
||||
schemaVersion: "agent-ui@1";
|
||||
component: string;
|
||||
surface: UISurface;
|
||||
props: unknown;
|
||||
data?: unknown;
|
||||
fallbackText?: string;
|
||||
}
|
||||
| {
|
||||
kind: "chart";
|
||||
schemaVersion: "agent-ui@1";
|
||||
grammar: "echarts-safe-subset";
|
||||
surface: "chat_inline" | "side_panel" | "canvas";
|
||||
spec: unknown;
|
||||
data: unknown;
|
||||
fallbackText?: string;
|
||||
}
|
||||
| {
|
||||
kind: "map_action";
|
||||
schemaVersion: "agent-ui@1";
|
||||
action: string;
|
||||
surface: "map_overlay";
|
||||
params: unknown;
|
||||
fallbackText?: string;
|
||||
};
|
||||
|
||||
export type UIEnvelopePayload = {
|
||||
session_id: string;
|
||||
envelope_id: string;
|
||||
created_at: number;
|
||||
envelope: UIEnvelope;
|
||||
};
|
||||
|
||||
export type UIRegistry = {
|
||||
schema_version: "agent-ui-registry@1";
|
||||
chart_grammars: string[];
|
||||
components: Array<{ id: string; supportedSurfaces: UISurface[] }>;
|
||||
actions: Array<{ id: string; supportedSurfaces: UISurface[] }>;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUiEnvelopeAllowed, parseUiEnvelope, parseUiRegistry } from "./validator";
|
||||
import type { UIRegistry } from "./types";
|
||||
|
||||
const registry: UIRegistry = {
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: ["echarts-safe-subset"],
|
||||
components: [
|
||||
{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] },
|
||||
{ id: "ScadaPanel", supportedSurfaces: ["side_panel", "canvas"] }
|
||||
],
|
||||
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
|
||||
};
|
||||
|
||||
describe("UIEnvelope validation", () => {
|
||||
it("rejects registered components outside the frontend allowlist", () => {
|
||||
const envelope = parseUiEnvelope({
|
||||
kind: "registered_component",
|
||||
schemaVersion: "agent-ui@1",
|
||||
component: "UnsafePanel",
|
||||
surface: "side_panel",
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(envelope).not.toBeNull();
|
||||
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects registered components on unsupported surfaces", () => {
|
||||
const envelope = parseUiEnvelope({
|
||||
kind: "registered_component",
|
||||
schemaVersion: "agent-ui@1",
|
||||
component: "HistoryPanel",
|
||||
surface: "chat_inline",
|
||||
props: {}
|
||||
});
|
||||
|
||||
expect(envelope).not.toBeNull();
|
||||
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only the echarts-safe-subset chart grammar", () => {
|
||||
expect(
|
||||
parseUiEnvelope({
|
||||
kind: "chart",
|
||||
schemaVersion: "agent-ui@1",
|
||||
grammar: "vega-lite",
|
||||
surface: "chat_inline",
|
||||
spec: {},
|
||||
data: {}
|
||||
})
|
||||
).toBeNull();
|
||||
|
||||
const envelope = parseUiEnvelope({
|
||||
kind: "chart",
|
||||
schemaVersion: "agent-ui@1",
|
||||
grammar: "echarts-safe-subset",
|
||||
surface: "chat_inline",
|
||||
spec: {},
|
||||
data: {}
|
||||
});
|
||||
|
||||
expect(envelope).not.toBeNull();
|
||||
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true);
|
||||
});
|
||||
|
||||
it("filters invalid registry surfaces before allowlist checks", () => {
|
||||
const parsed = parseUiRegistry({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: ["echarts-safe-subset"],
|
||||
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unsafe_surface"] }],
|
||||
actions: []
|
||||
});
|
||||
|
||||
expect(parsed?.components[0]?.supportedSurfaces).toEqual(["side_panel"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
|
||||
|
||||
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
|
||||
|
||||
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope = parseUiEnvelope(value.envelope);
|
||||
if (!envelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
session_id: typeof value.session_id === "string" ? value.session_id : "",
|
||||
envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "",
|
||||
created_at: typeof value.created_at === "number" ? value.created_at : Date.now(),
|
||||
envelope
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUiEnvelope(value: unknown): UIEnvelope | null {
|
||||
if (!isRecord(value) || value.schemaVersion !== "agent-ui@1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.kind === "registered_component") {
|
||||
if (typeof value.component !== "string" || !isSurface(value.surface)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "registered_component",
|
||||
schemaVersion: "agent-ui@1",
|
||||
component: value.component,
|
||||
surface: value.surface,
|
||||
props: value.props,
|
||||
data: value.data,
|
||||
fallbackText: optionalString(value.fallbackText)
|
||||
};
|
||||
}
|
||||
|
||||
if (value.kind === "chart") {
|
||||
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "chart",
|
||||
schemaVersion: "agent-ui@1",
|
||||
grammar: "echarts-safe-subset",
|
||||
surface: value.surface,
|
||||
spec: value.spec,
|
||||
data: value.data,
|
||||
fallbackText: optionalString(value.fallbackText)
|
||||
};
|
||||
}
|
||||
|
||||
if (value.kind === "map_action") {
|
||||
if (typeof value.action !== "string" || value.surface !== "map_overlay") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "map_action",
|
||||
schemaVersion: "agent-ui@1",
|
||||
action: value.action,
|
||||
surface: "map_overlay",
|
||||
params: value.params,
|
||||
fallbackText: optionalString(value.fallbackText)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
|
||||
if (!registry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
const manifest = registry.components.find((item) => item.id === envelope.component);
|
||||
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||
}
|
||||
|
||||
if (envelope.kind === "chart") {
|
||||
return registry.chart_grammars.includes(envelope.grammar);
|
||||
}
|
||||
|
||||
const manifest = registry.actions.find((item) => item.id === envelope.action);
|
||||
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
|
||||
}
|
||||
|
||||
export function parseUiRegistry(value: unknown): UIRegistry | null {
|
||||
if (!isRecord(value) || value.schema_version !== "agent-ui-registry@1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: stringArray(value.chart_grammars),
|
||||
components: manifestArray(value.components),
|
||||
actions: manifestArray(value.actions)
|
||||
};
|
||||
}
|
||||
|
||||
function manifestArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!isRecord(item) || typeof item.id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ id: item.id, supportedSurfaces: stringArray(item.supportedSurfaces).filter(isSurface) }];
|
||||
});
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function isChartSurface(value: unknown): value is "chat_inline" | "side_panel" | "canvas" {
|
||||
return value === "chat_inline" || value === "side_panel" || value === "canvas";
|
||||
}
|
||||
|
||||
function isSurface(value: unknown): value is UISurface {
|
||||
return typeof value === "string" && surfaces.has(value as UISurface);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapPanelSection
|
||||
} from "./control-panel";
|
||||
import {
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapAnnotationItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
status?: string;
|
||||
selected?: boolean;
|
||||
muted?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapAnnotationShareAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapAnnotationPanelProps = {
|
||||
annotations: MapAnnotationItem[];
|
||||
shareActions?: MapAnnotationShareAction[];
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
export function MapAnnotationPanel({
|
||||
annotations,
|
||||
shareActions = [],
|
||||
emptyText = "暂无标注"
|
||||
}: MapAnnotationPanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="标注列表">
|
||||
{annotations.length > 0 ? (
|
||||
annotations.map((annotation) => (
|
||||
<MapActionRow
|
||||
key={annotation.id}
|
||||
icon={annotation.icon ?? getDefaultAnnotationIcon(annotation.status)}
|
||||
label={annotation.label}
|
||||
description={annotation.description}
|
||||
status={annotation.status}
|
||||
selected={annotation.selected}
|
||||
muted={annotation.muted}
|
||||
onClick={annotation.onClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</MapPanelSection>
|
||||
|
||||
{shareActions.length > 0 ? (
|
||||
<MapPanelSection title="共享">
|
||||
{shareActions.map((action) => (
|
||||
<MapActionRow
|
||||
key={action.id}
|
||||
icon={action.icon ?? getDefaultShareIcon(action.id)}
|
||||
label={action.label}
|
||||
description={action.description}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</MapPanelSection>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultAnnotationIcon(status?: string) {
|
||||
if (status) {
|
||||
return ListChecks;
|
||||
}
|
||||
|
||||
return MapPinned;
|
||||
}
|
||||
|
||||
function getDefaultShareIcon(actionId: string) {
|
||||
if (actionId.includes("export") || actionId.includes("download")) {
|
||||
return Download;
|
||||
}
|
||||
|
||||
if (actionId.includes("share")) {
|
||||
return Share2;
|
||||
}
|
||||
|
||||
return Plus;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Map } from "lucide-react";
|
||||
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type BaseLayerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
swatchClassName?: string;
|
||||
};
|
||||
|
||||
type BaseLayersControlProps = {
|
||||
options: BaseLayerOption[];
|
||||
activeLayerId: string;
|
||||
onSelectLayer: (id: string) => void;
|
||||
title?: string;
|
||||
variant?: "floating" | "panel";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BaseLayersControl({
|
||||
options,
|
||||
activeLayerId,
|
||||
onSelectLayer,
|
||||
title = "底图",
|
||||
variant = "floating",
|
||||
className = ""
|
||||
}: BaseLayersControlProps) {
|
||||
const activeLayer = options.find((option) => option.id === activeLayerId) ?? options[0];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openControl = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
setIsOpen(true);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, 260);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleClose();
|
||||
},
|
||||
[scheduleClose]
|
||||
);
|
||||
|
||||
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
||||
|
||||
if (variant === "panel") {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[276px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Map size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
当前 {activeLayer?.label ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active
|
||||
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
|
||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
onMouseEnter={openControl}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocus={openControl}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"pointer-events-auto relative flex h-[96px] items-end justify-end",
|
||||
isOpen ? "w-[384px]" : "w-[96px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 right-[104px] grid h-[96px] grid-cols-[repeat(3,84px)] gap-2 p-1.5 transition-opacity duration-150",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span
|
||||
data-base-layer-label="true"
|
||||
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
{activeLayer ? (
|
||||
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
|
||||
) : (
|
||||
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1 right-1 rounded bg-white/95 px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
|
||||
{activeLayer?.label ?? title}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSwatchProps = {
|
||||
option: BaseLayerOption;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
option.swatchClassName ?? "bg-slate-100"
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
|
||||
<Check size={10} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapControlPanelProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
widthClassName?: string;
|
||||
visible?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MapControlPanel({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
widthClassName = "w-[292px]",
|
||||
visible = true,
|
||||
children
|
||||
}: MapControlPanelProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"relative origin-top-right p-3 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
|
||||
visible
|
||||
? "pointer-events-auto opacity-100 translate-x-0 scale-100 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-right-2 motion-safe:zoom-in-95 motion-safe:duration-150 motion-safe:ease-out"
|
||||
: "pointer-events-none translate-x-2 scale-[0.98] opacity-0",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
widthClassName
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapModeButtonProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function MapModeButton({ icon: Icon, label, active = false, disabled = false, onClick }: MapModeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
active ? "bg-blue-600 text-white shadow-sm" : "bg-white/95 text-slate-600 hover:text-blue-700",
|
||||
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapActionChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapActionRowProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
strong?: boolean;
|
||||
variant?: "default" | "primary";
|
||||
selected?: boolean;
|
||||
muted?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function MapActionRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
strong = false,
|
||||
variant = "default",
|
||||
selected = false,
|
||||
muted = false,
|
||||
disabled = false,
|
||||
tone = "default",
|
||||
onClick
|
||||
}: MapActionRowProps) {
|
||||
const isDanger = tone === "danger";
|
||||
const isPrimary = variant === "primary";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
isPrimary
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
||||
: strong
|
||||
? "border-blue-100 bg-blue-50/95 text-blue-800"
|
||||
: "border-white/70 bg-white/95 text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
|
||||
muted && "opacity-70",
|
||||
disabled &&
|
||||
(isPrimary
|
||||
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
|
||||
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
|
||||
disabled && "active:translate-y-0 active:scale-100"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
isPrimary
|
||||
? "bg-white/16 text-white"
|
||||
: selected || strong
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-slate-100 text-slate-500",
|
||||
isDanger && "bg-red-50 text-red-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{label}</span>
|
||||
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
|
||||
</span>
|
||||
{status ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapMetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapDrawTool = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapDrawToolbarProps = {
|
||||
items: MapDrawTool[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
variant?: "compact" | "panel";
|
||||
};
|
||||
|
||||
export function MapDrawToolbar({ items, label = "绘制工具", className = "", variant = "compact" }: MapDrawToolbarProps) {
|
||||
if (variant === "panel") {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
className={cn("pointer-events-auto grid grid-cols-2 gap-1.5", className)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDanger = item.tone === "danger";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.active
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-md shadow-blue-600/20"
|
||||
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
|
||||
isDanger && "bg-red-50 text-red-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto inline-flex p-1",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDanger = item.tone === "danger";
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
item.active
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "hover:bg-blue-50 hover:text-blue-700",
|
||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={7}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLayerControlItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
type MapLayerControlProps = {
|
||||
title?: string;
|
||||
items: MapLayerControlItem[];
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapLayerControl({
|
||||
title = "图层",
|
||||
items,
|
||||
onToggleLayer,
|
||||
className = ""
|
||||
}: MapLayerControlProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[276px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Layers3 size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{items.filter((item) => item.visible).length}/{items.length} 可见
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.visible ? Eye : EyeOff;
|
||||
const disabled = item.disabled || item.locked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={item.visible}
|
||||
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-100 bg-white/95 text-slate-900 shadow-md shadow-blue-950/10"
|
||||
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}
|
||||
>
|
||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
item.visible ? "bg-blue-600" : "bg-slate-300"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BaseLayerOption } from "./base-layers-control";
|
||||
import type { MapLayerControlItem } from "./layer-control";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLayerPanelProps = {
|
||||
layerItems: MapLayerControlItem[];
|
||||
baseLayerOptions: BaseLayerOption[];
|
||||
activeBaseLayerId: string;
|
||||
onSelectBaseLayer: (id: string) => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function MapLayerPanel({
|
||||
layerItems,
|
||||
baseLayerOptions,
|
||||
activeBaseLayerId,
|
||||
onSelectBaseLayer,
|
||||
onToggleLayer
|
||||
}: MapLayerPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<LayerVisibilitySection items={layerItems} onToggleLayer={onToggleLayer} />
|
||||
<BaseLayerSection
|
||||
options={baseLayerOptions}
|
||||
activeLayerId={activeBaseLayerId}
|
||||
onSelectLayer={onSelectBaseLayer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LayerVisibilitySectionProps = {
|
||||
items: MapLayerControlItem[];
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.visible ? Eye : EyeOff;
|
||||
const disabled = item.disabled || item.locked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={item.visible}
|
||||
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-100 bg-white/95 text-slate-900"
|
||||
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}
|
||||
>
|
||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 rounded-full", item.visible ? "bg-blue-600" : "bg-slate-300")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSectionProps = {
|
||||
options: BaseLayerOption[];
|
||||
activeLayerId: string;
|
||||
onSelectLayer: (id: string) => void;
|
||||
};
|
||||
|
||||
function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-slate-500">底图</span>
|
||||
<Map size={14} aria-hidden="true" className="text-slate-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active
|
||||
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
|
||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSwatchProps = {
|
||||
option: BaseLayerOption;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
option.swatchClassName ?? "bg-slate-100"
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
|
||||
<Check size={10} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLegendItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
icon?: LucideIcon;
|
||||
shape?: "line" | "dot" | "square";
|
||||
};
|
||||
|
||||
type MapLegendProps = {
|
||||
title?: string;
|
||||
items: MapLegendItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapLegend({ title = "图例", items, className = "" }: MapLegendProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[236px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
|
||||
<MapLegendList items={items} className="mt-3" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type MapLegendListProps = {
|
||||
items: MapLegendItem[];
|
||||
className?: string;
|
||||
showStatusDot?: boolean;
|
||||
};
|
||||
|
||||
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
|
||||
return (
|
||||
<ul className={cn("space-y-1.5", className)}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
{Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
|
||||
if (shape === "line") {
|
||||
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
if (shape === "square") {
|
||||
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
|
||||
|
||||
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_READABLE_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_COMPACT_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
||||
|
||||
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
||||
"border border-white/75 bg-white/95 shadow-lg shadow-slate-900/20 ring-1 ring-slate-900/5 backdrop-blur-xl";
|
||||
|
||||
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
|
||||
"border border-white/70 bg-white/80 shadow-2xl shadow-blue-950/20 backdrop-blur-xl";
|
||||
|
||||
export const MAP_READABLE_SURFACE_CLASS_NAME =
|
||||
"border border-white/70 bg-white/95";
|
||||
|
||||
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
|
||||
"border border-white/80 bg-white/95";
|
||||
|
||||
export const MAP_CONTROL_HOVER_CLASS_NAME =
|
||||
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Copy, Ruler, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapModeButton,
|
||||
MapPanelSection
|
||||
} from "./control-panel";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapMeasureMode = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type MapMeasureUnit = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type MapMeasureResultMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type MapMeasureAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
strong?: boolean;
|
||||
variant?: "default" | "primary";
|
||||
tone?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapMeasurePanelProps = {
|
||||
modes: MapMeasureMode[];
|
||||
activeModeId: string;
|
||||
resultLabel: string;
|
||||
resultValue: string;
|
||||
resultUnit: string;
|
||||
metrics?: MapMeasureResultMetric[];
|
||||
units: MapMeasureUnit[];
|
||||
activeUnitId: string;
|
||||
actions: MapMeasureAction[];
|
||||
onSelectMode?: (id: string) => void;
|
||||
onSelectUnit?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MapMeasurePanel({
|
||||
modes,
|
||||
activeModeId,
|
||||
resultLabel,
|
||||
resultValue,
|
||||
resultUnit,
|
||||
metrics = [],
|
||||
units,
|
||||
activeUnitId,
|
||||
actions,
|
||||
onSelectMode,
|
||||
onSelectUnit
|
||||
}: MapMeasurePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="模式">
|
||||
<div className={cn("grid grid-cols-3 gap-1.5 border border-white/60 bg-white/80 p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
{modes.map((mode) => (
|
||||
<MapModeButton
|
||||
key={mode.id}
|
||||
icon={mode.icon}
|
||||
label={mode.label}
|
||||
active={mode.id === activeModeId}
|
||||
disabled={mode.disabled}
|
||||
onClick={() => onSelectMode?.(mode.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="结果">
|
||||
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<div className="min-w-0 border-b border-slate-100 pb-3">
|
||||
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
|
||||
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
|
||||
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.length > 0 ? (
|
||||
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<div className="truncate text-xs text-slate-500">{metric.label}</div>
|
||||
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
|
||||
{units.map((unit) => (
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => onSelectUnit?.(unit.id)}
|
||||
className={cn(
|
||||
"h-7 text-xs font-semibold transition",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
unit.id === activeUnitId
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white/95 text-slate-600 hover:text-blue-700"
|
||||
)}
|
||||
>
|
||||
{unit.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="操作">
|
||||
{actions.map((action) => (
|
||||
<MapActionRow
|
||||
key={action.id}
|
||||
icon={action.icon ?? getDefaultActionIcon(action.id)}
|
||||
label={action.label}
|
||||
description={action.description}
|
||||
strong={action.strong}
|
||||
variant={action.variant}
|
||||
tone={action.tone}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultActionIcon(actionId: string) {
|
||||
if (actionId.includes("copy")) {
|
||||
return Copy;
|
||||
}
|
||||
|
||||
if (actionId.includes("clear") || actionId.includes("delete")) {
|
||||
return Trash2;
|
||||
}
|
||||
|
||||
return Ruler;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
||||
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
export type MapNoticeOptions = {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
message: string;
|
||||
tone?: MapNoticeTone;
|
||||
duration?: number;
|
||||
position?: MapNoticePosition;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
dismissible?: boolean;
|
||||
};
|
||||
|
||||
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||
|
||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||
|
||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||
"top-center": "top-[84px]",
|
||||
"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: "border-white/80 bg-white/95 text-slate-900",
|
||||
success: "border-white/80 bg-white/95 text-slate-900",
|
||||
warning: "border-white/80 bg-white/95 text-slate-900",
|
||||
error: "border-white/80 bg-white/95 text-slate-900",
|
||||
loading: "border-white/80 bg-white/95 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]", 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-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm shadow-lg shadow-slate-900/20 backdrop-blur-xl",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
toneClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
iconClassNames[tone],
|
||||
noticeIconSurfaceClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeBody({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeCloseIcon() {
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">关闭通知</span>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNoticeIcon(tone: MapNoticeTone) {
|
||||
if (tone === "success") {
|
||||
return CheckCircle2;
|
||||
}
|
||||
|
||||
if (tone === "warning") {
|
||||
return AlertTriangle;
|
||||
}
|
||||
|
||||
if (tone === "error") {
|
||||
return XCircle;
|
||||
}
|
||||
|
||||
if (tone === "loading") {
|
||||
return Loader2;
|
||||
}
|
||||
|
||||
return Info;
|
||||
}
|
||||
|
||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||
if (status === "online") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return `${sourceName} 已连接`;
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return `${sourceName} 降级运行`;
|
||||
}
|
||||
|
||||
return `${sourceName} 不可用`;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return "地图数据源已恢复正常。";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "部分地图能力不可用,系统已使用可用替代方案。";
|
||||
}
|
||||
|
||||
return "地图数据源请求失败,请检查网络或稍后重试。";
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
|
||||
type ScaleLineState = {
|
||||
label: string;
|
||||
width: number;
|
||||
zoom: string;
|
||||
};
|
||||
|
||||
type MapScaleLineProps = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
maxWidth?: number;
|
||||
coordinatePrecision?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type CoordinateState = {
|
||||
lng: number;
|
||||
lat: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SCALE: ScaleLineState = {
|
||||
label: "500 m",
|
||||
width: 80,
|
||||
zoom: "Z --"
|
||||
};
|
||||
|
||||
const MIN_SCALE_WIDTH = 34;
|
||||
const COORDINATE_READOUT_WIDTH_CLASS_NAME = "w-[160px]";
|
||||
|
||||
export function MapScaleLine({
|
||||
mapRef,
|
||||
mapReady,
|
||||
maxWidth = 96,
|
||||
coordinatePrecision = 5,
|
||||
className = ""
|
||||
}: MapScaleLineProps) {
|
||||
const [scale, setScale] = useState<ScaleLineState>(DEFAULT_SCALE);
|
||||
const [coordinate, setCoordinate] = useState<CoordinateState | null>(null);
|
||||
const coordinateReadout = coordinate
|
||||
? createCoordinateReadout(coordinate, coordinatePrecision)
|
||||
: null;
|
||||
const coordinateLabel = coordinateReadout
|
||||
? `${coordinateReadout.mercatorText} · ${coordinateReadout.lngLatText}`
|
||||
: "移动鼠标读取坐标";
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScale = () => {
|
||||
setScale(createScaleLineState(map, maxWidth));
|
||||
};
|
||||
|
||||
updateScale();
|
||||
map.on("move", updateScale);
|
||||
map.on("zoom", updateScale);
|
||||
map.on("resize", updateScale);
|
||||
|
||||
return () => {
|
||||
map.off("move", updateScale);
|
||||
map.off("zoom", updateScale);
|
||||
map.off("resize", updateScale);
|
||||
};
|
||||
}, [mapReady, mapRef, maxWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: { lngLat: { lng: number; lat: number } }) => {
|
||||
setCoordinate({
|
||||
lng: event.lngLat.lng,
|
||||
lat: event.lngLat.lat
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
setCoordinate(null);
|
||||
};
|
||||
|
||||
map.on("mousemove", handleMove);
|
||||
map.on("mouseout", handleLeave);
|
||||
|
||||
return () => {
|
||||
map.off("mousemove", handleMove);
|
||||
map.off("mouseout", handleLeave);
|
||||
};
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`比例尺 ${scale.label},${coordinateLabel}`}
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="min-w-11 whitespace-nowrap text-right tabular-nums">{scale.label}</span>
|
||||
<div
|
||||
className="relative h-2 shrink-0 border-x-2 border-b-2 border-slate-800 transition-[width,border-color] duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ width: `${scale.width}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScaleLineDivider />
|
||||
|
||||
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
|
||||
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
||||
<span className="text-slate-600">EPSG:3857</span>
|
||||
{coordinateReadout ? (
|
||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
||||
{coordinateReadout.mercatorText}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
|
||||
)}
|
||||
<span className="text-slate-500">Mapbox/OSM</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaleLineDivider() {
|
||||
return (
|
||||
<div
|
||||
className="h-3.5 w-px shrink-0 bg-slate-300/70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createScaleLineState(map: MapLibreMap, maxWidth: number): ScaleLineState {
|
||||
const canvas = map.getCanvas();
|
||||
const canvasWidth = canvas.clientWidth || canvas.width;
|
||||
const center = map.project(map.getCenter());
|
||||
const halfWidth = Math.min(maxWidth / 2, Math.max(1, canvasWidth / 2));
|
||||
const y = Math.round(center.y);
|
||||
const start = map.unproject([Math.max(0, center.x - halfWidth), y]);
|
||||
const end = map.unproject([Math.min(canvasWidth, center.x + halfWidth), y]);
|
||||
const measuredWidth = Math.max(1, Math.min(canvasWidth, center.x + halfWidth) - Math.max(0, center.x - halfWidth));
|
||||
const metersPerMaxWidth = distanceInMeters(start.lng, start.lat, end.lng, end.lat);
|
||||
if (!Number.isFinite(metersPerMaxWidth) || metersPerMaxWidth <= 0) {
|
||||
return {
|
||||
...DEFAULT_SCALE,
|
||||
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||
};
|
||||
}
|
||||
|
||||
const niceMeters = getNiceDistance(metersPerMaxWidth);
|
||||
const widthPx = Math.min(
|
||||
measuredWidth,
|
||||
Math.max(Math.min(MIN_SCALE_WIDTH, measuredWidth), Math.round((niceMeters / metersPerMaxWidth) * measuredWidth))
|
||||
);
|
||||
|
||||
return {
|
||||
label: formatDistance(niceMeters),
|
||||
width: widthPx,
|
||||
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||
};
|
||||
}
|
||||
|
||||
function getNiceDistance(maxMeters: number) {
|
||||
const target = Math.max(1, maxMeters);
|
||||
const magnitude = 10 ** Math.floor(Math.log10(target));
|
||||
const normalized = target / magnitude;
|
||||
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
|
||||
|
||||
return multiplier * magnitude;
|
||||
}
|
||||
|
||||
function formatDistance(meters: number) {
|
||||
if (meters >= 1000) {
|
||||
return `${Number((meters / 1000).toFixed(meters >= 10000 ? 0 : 1))} km`;
|
||||
}
|
||||
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
function createCoordinateReadout(coordinate: CoordinateState, precision: number) {
|
||||
return {
|
||||
lngLatText: `Lng ${coordinate.lng.toFixed(precision)} Lat ${coordinate.lat.toFixed(precision)}`,
|
||||
mercatorText: formatWebMercator(coordinate.lng, coordinate.lat)
|
||||
};
|
||||
}
|
||||
|
||||
function formatWebMercator(lng: number, lat: number) {
|
||||
const earthRadius = 6378137;
|
||||
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
||||
const x = earthRadius * toRadians(lng);
|
||||
const y = earthRadius * Math.log(Math.tan(Math.PI / 4 + toRadians(clampedLat) / 2));
|
||||
|
||||
return `X ${Math.round(x).toLocaleString("en-US")} Y ${Math.round(y).toLocaleString("en-US")}`;
|
||||
}
|
||||
|
||||
function distanceInMeters(startLng: number, startLat: number, endLng: number, endLat: number) {
|
||||
const earthRadius = 6371008.8;
|
||||
const startPhi = toRadians(startLat);
|
||||
const endPhi = toRadians(endLat);
|
||||
const deltaPhi = toRadians(endLat - startLat);
|
||||
const deltaLambda = toRadians(endLng - startLng);
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(startPhi) * Math.cos(endPhi) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||
|
||||
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function toRadians(degrees: number) {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import {
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapToolbarItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type MapToolbarProps = {
|
||||
items: MapToolbarItem[];
|
||||
label?: string;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapToolbar({
|
||||
items,
|
||||
label = "地图工具",
|
||||
orientation = "vertical",
|
||||
className = ""
|
||||
}: MapToolbarProps) {
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto p-[3px]",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((tool, index) => {
|
||||
const Icon = tool.icon;
|
||||
const title = tool.description ? `${tool.label}:${tool.description}` : tool.label;
|
||||
|
||||
return (
|
||||
<div key={tool.id} className={cn(isVertical ? "contents" : "flex items-center")}>
|
||||
{index > 0 ? <ToolbarDivider vertical={isVertical} /> : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
aria-pressed={tool.active}
|
||||
disabled={tool.disabled}
|
||||
onClick={tool.onClick}
|
||||
className={cn(
|
||||
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
tool.active
|
||||
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
|
||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||
<span className="sr-only">{tool.label}</span>
|
||||
{tool.badge ? (
|
||||
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
{tool.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={isVertical ? "left" : "top"}
|
||||
sideOffset={8}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider({ vertical }: { vertical: boolean }) {
|
||||
if (vertical) {
|
||||
return (
|
||||
<div
|
||||
className="my-0.5 flex h-px w-full items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="block h-px w-3.5 bg-slate-300/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-0.5 flex h-full w-px items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="block h-3.5 w-px bg-slate-300/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
|
||||
import { type RefObject, useCallback } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
type MapZoomProps = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
onHome: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
|
||||
const handleZoomIn = useCallback(() => {
|
||||
mapRef.current?.zoomIn({ duration: 260 });
|
||||
}, [mapRef]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
mapRef.current?.zoomOut({ duration: 260 });
|
||||
}, [mapRef]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div
|
||||
aria-label="地图缩放"
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-[46px] flex-col p-[3px]",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ControlButton
|
||||
icon={Plus}
|
||||
label="放大"
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomIn}
|
||||
/>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
icon={Minus}
|
||||
label="缩小"
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomOut}
|
||||
/>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
icon={Home}
|
||||
label="缩放到全局"
|
||||
disabled={!mapReady}
|
||||
onClick={onHome}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ControlButtonProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
"bg-transparent text-slate-700",
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
disabled &&
|
||||
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div
|
||||
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
getRunningWorkflowDefinition,
|
||||
getRunningWorkflowState
|
||||
} from "../data/running-workflows";
|
||||
import type {
|
||||
ScheduledConditionRecord
|
||||
} from "../types";
|
||||
|
||||
export type ConditionStepTickerItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type ConditionStepTickerView = {
|
||||
title: string;
|
||||
progressLabel: string;
|
||||
progressValue: number;
|
||||
currentStep: ConditionStepTickerItem | null;
|
||||
};
|
||||
|
||||
export type TickerStackCard = {
|
||||
condition: ScheduledConditionRecord;
|
||||
view: ConditionStepTickerView;
|
||||
stackIndex: number;
|
||||
};
|
||||
|
||||
const TICKER_STACK_DEPTH = 3;
|
||||
|
||||
export function selectTickerCondition(
|
||||
runningConditions: ScheduledConditionRecord[],
|
||||
rotationIndex: number
|
||||
): ScheduledConditionRecord | null {
|
||||
if (runningConditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runningConditions[rotationIndex % runningConditions.length] ?? runningConditions[0] ?? null;
|
||||
}
|
||||
|
||||
export function createVisibleTickerCards(
|
||||
runningConditions: ScheduledConditionRecord[],
|
||||
rotationIndex: number,
|
||||
nowMs: number
|
||||
): TickerStackCard[] {
|
||||
if (runningConditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visibleCount = Math.min(runningConditions.length, TICKER_STACK_DEPTH);
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, stackIndex) => {
|
||||
const conditionIndex = (rotationIndex + stackIndex) % runningConditions.length;
|
||||
const condition = runningConditions[conditionIndex] ?? runningConditions[0];
|
||||
|
||||
return {
|
||||
condition,
|
||||
view: createConditionStepView(condition, nowMs, runningConditions.length),
|
||||
stackIndex
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createConditionStepView(
|
||||
condition: ScheduledConditionRecord,
|
||||
nowMs: number,
|
||||
runningConditionCount: number
|
||||
): ConditionStepTickerView {
|
||||
const workflow = getRunningWorkflowDefinition(condition);
|
||||
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
|
||||
const currentStep = workflow.steps[workflowState.currentStepIndex];
|
||||
const currentStepItem: ConditionStepTickerItem | null = currentStep
|
||||
? {
|
||||
id: currentStep.id,
|
||||
title: `步骤 ${workflowState.currentStepIndex + 1}/${workflow.steps.length} ${currentStep.title}`,
|
||||
detail: currentStep.detail
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
title: runningConditionCount > 1
|
||||
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
|
||||
: `${workflow.title} · ${condition.title}`,
|
||||
progressLabel: `${workflowState.progress}%`,
|
||||
progressValue: workflowState.progress,
|
||||
currentStep: currentStepItem
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
|
||||
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
|
||||
|
||||
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
|
||||
return {
|
||||
id,
|
||||
kind: "condition",
|
||||
taskId,
|
||||
scheduledAt: "2026-07-08T10:00:00+08:00",
|
||||
title: id,
|
||||
summary: "Running condition",
|
||||
status: "running",
|
||||
riskLevel: "normal",
|
||||
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
|
||||
sessionId: id,
|
||||
durationMinutes: 10
|
||||
};
|
||||
}
|
||||
|
||||
describe("createVisibleTickerCards", () => {
|
||||
it("wraps the final running condition back to the first cards", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch"),
|
||||
createRunningCondition("condition-c", "pump-energy"),
|
||||
createRunningCondition("condition-d", "network-simulation")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
conditions.length - 1,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||
"condition-d",
|
||||
"condition-a",
|
||||
"condition-b"
|
||||
]);
|
||||
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("does not duplicate the same task to fill stack slots", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
0,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual(["condition-a"]);
|
||||
});
|
||||
|
||||
it("uses two stack slots for two running tasks", () => {
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch")
|
||||
];
|
||||
|
||||
const cards = createVisibleTickerCards(
|
||||
conditions,
|
||||
1,
|
||||
Date.parse("2026-07-08T10:01:00+08:00")
|
||||
);
|
||||
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards.map((card) => card.condition.id)).toEqual([
|
||||
"condition-b",
|
||||
"condition-a"
|
||||
]);
|
||||
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ScheduledConditionRecord
|
||||
} from "@/features/workbench/types";
|
||||
import {
|
||||
createVisibleTickerCards,
|
||||
type TickerStackCard
|
||||
} from "./agent-task-ticker-utils";
|
||||
|
||||
type AgentTaskTickerProps = {
|
||||
conditions: ScheduledConditionRecord[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TickerTransitionDirection = -1 | 1;
|
||||
|
||||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||
const tickerSwitchAnimationMs = 380;
|
||||
const tickerCardSurfaceClassName =
|
||||
"border border-white/75 bg-white/[0.92] ring-1 ring-slate-900/5 backdrop-blur-xl";
|
||||
|
||||
const tickerCardVariants: Variants = {
|
||||
initial: (direction: TickerTransitionDirection) => ({
|
||||
opacity: 0.62,
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.955) saturate(0.95)"
|
||||
}),
|
||||
exit: (direction: TickerTransitionDirection) => ({
|
||||
opacity: [1, 1, 0],
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(1.02) saturate(1)",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
times: [0, 0.65, 1],
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const tickerContentVariants: Variants = {
|
||||
initial: {
|
||||
clipPath: "inset(0 100% 0 0)"
|
||||
},
|
||||
animate: {
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
clipPath: "inset(0 0 0 100%)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tickerIndicatorVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.16,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96,
|
||||
transition: {
|
||||
duration: 0.12,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function AgentTaskTicker({
|
||||
conditions,
|
||||
className
|
||||
}: AgentTaskTickerProps) {
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const isTaskSwitchLockedRef = useRef(false);
|
||||
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [rotationIndex, setRotationIndex] = useState(0);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
|
||||
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
|
||||
id: 0,
|
||||
active: false
|
||||
}));
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const runningConditions = conditions;
|
||||
const visibleTickerCards = useMemo(
|
||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||
[runningConditions, rotationIndex, nowMs]
|
||||
);
|
||||
const hasMultipleRunningTasks = runningConditions.length > 1;
|
||||
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
|
||||
const tickerLabel = hasMultipleRunningTasks
|
||||
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
|
||||
: "工况任务步骤";
|
||||
|
||||
const releaseTaskSwitchLock = useCallback(() => {
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const finishTaskSwitchAnimation = useCallback(() => {
|
||||
setTaskSwitchAnimation((current) => (
|
||||
current.active ? { ...current, active: false } : current
|
||||
));
|
||||
}, []);
|
||||
|
||||
const lockTaskSwitch = useCallback(() => {
|
||||
isTaskSwitchLockedRef.current = true;
|
||||
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
}
|
||||
|
||||
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
finishTaskSwitchAnimation();
|
||||
}, tickerSwitchAnimationMs);
|
||||
}, [finishTaskSwitchAnimation]);
|
||||
|
||||
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
|
||||
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockTaskSwitch();
|
||||
setTransitionDirection(direction);
|
||||
setTaskSwitchAnimation((current) => ({
|
||||
id: current.id + 1,
|
||||
active: true
|
||||
}));
|
||||
setRotationIndex((current) => (
|
||||
(current + direction + runningConditions.length) % runningConditions.length
|
||||
));
|
||||
}, [lockTaskSwitch, runningConditions.length]);
|
||||
|
||||
const showPreviousTask = useCallback(() => {
|
||||
shiftTask(-1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const showNextTask = useCallback(() => {
|
||||
shiftTask(1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
if (!hasMultipleRunningTasks || event.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.deltaY > 0) {
|
||||
showNextTask();
|
||||
return;
|
||||
}
|
||||
|
||||
showPreviousTask();
|
||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationIndex(0);
|
||||
releaseTaskSwitchLock();
|
||||
finishTaskSwitchAnimation();
|
||||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
|
||||
|
||||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
section.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
return () => section.removeEventListener("wheel", handleWheel);
|
||||
}, [handleWheel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleRunningTasks || isHovered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
shiftTask(1);
|
||||
}, 6_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
||||
|
||||
if (visibleTickerCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={cn(
|
||||
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
|
||||
className
|
||||
)}
|
||||
aria-label={tickerLabel}
|
||||
title={tickerLabel}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
||||
{visibleTickerCards
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((card) => (
|
||||
<TickerTaskCard
|
||||
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
|
||||
card={card}
|
||||
isActive={card.stackIndex === 0}
|
||||
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
|
||||
transitionDirection={transitionDirection}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
|
||||
<AnimatePresence initial={false}>
|
||||
{isHovered && hasMultipleRunningTasks ? (
|
||||
<motion.div
|
||||
key="task-indicator"
|
||||
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-white/80 px-1 py-1 shadow-sm shadow-blue-950/10 backdrop-blur"
|
||||
variants={tickerIndicatorVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
{runningConditions.map((runningCondition, index) => (
|
||||
<span
|
||||
key={runningCondition.id}
|
||||
className={cn(
|
||||
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
|
||||
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCard({
|
||||
card,
|
||||
isActive,
|
||||
animateContentChanges,
|
||||
transitionDirection
|
||||
}: {
|
||||
card: TickerStackCard;
|
||||
isActive: boolean;
|
||||
animateContentChanges: boolean;
|
||||
transitionDirection: TickerTransitionDirection;
|
||||
}) {
|
||||
const { condition, view, stackIndex } = card;
|
||||
const stackStyle = getTickerStackStyle(stackIndex);
|
||||
const contentKey = view.currentStep
|
||||
? `${condition.id}-${view.currentStep.id}`
|
||||
: `${condition.id}-pending`;
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
layout="position"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||||
tickerCardSurfaceClassName,
|
||||
isActive ? "z-30" : "pointer-events-none z-10"
|
||||
)}
|
||||
style={{ transformOrigin: "center top" }}
|
||||
custom={transitionDirection}
|
||||
variants={tickerCardVariants}
|
||||
initial="initial"
|
||||
animate={{
|
||||
opacity: stackStyle.opacity,
|
||||
x: stackStyle.x,
|
||||
y: stackStyle.y,
|
||||
rotate: stackStyle.rotate,
|
||||
filter: stackStyle.filter,
|
||||
transition: {
|
||||
duration: isActive ? 0.34 : 0.3,
|
||||
ease: tickerEase
|
||||
}
|
||||
}}
|
||||
exit="exit"
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
isActive ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence key={condition.id} initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={contentKey}
|
||||
className="h-full overflow-hidden"
|
||||
variants={tickerContentVariants}
|
||||
initial={animateContentChanges ? "initial" : false}
|
||||
animate={animateContentChanges ? "animate" : undefined}
|
||||
exit={animateContentChanges ? "exit" : undefined}
|
||||
>
|
||||
<TickerTaskCardContent view={view} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
|
||||
if (!view.currentStep) {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} muted />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="min-w-0 pr-1">
|
||||
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0" title={`${view.currentStep.title}:${view.currentStep.detail}`}>
|
||||
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
|
||||
{view.currentStep.title}
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
|
||||
{view.currentStep.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerProgressBar({
|
||||
progressLabel,
|
||||
muted = false
|
||||
}: {
|
||||
progressLabel: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-0.5 overflow-hidden rounded-full",
|
||||
muted ? "bg-slate-200/60" : "bg-slate-200/80"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.span
|
||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||
animate={{ width: progressLabel }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||
const dashOffset = 44 - (44 * progress) / 100;
|
||||
return (
|
||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
||||
<motion.circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="7"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="stroke-blue-600"
|
||||
strokeDasharray="44"
|
||||
animate={{ strokeDashoffset: dashOffset }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTickerStackStyle(stackIndex: number) {
|
||||
if (stackIndex === 1) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 7,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.985) saturate(0.98)"
|
||||
};
|
||||
}
|
||||
|
||||
if (stackIndex === 2) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 14,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.965) saturate(0.96)"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
filter: "brightness(1) saturate(1)"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Target, TriangleAlert, X } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getLocalizedFeatureProperties
|
||||
} from "../utils/feature-properties";
|
||||
|
||||
type FeatureInsightPanelProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||
const metrics = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (feature.layer === "pipes") {
|
||||
return [
|
||||
["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)],
|
||||
["长度", formatFeaturePropertyValue("length", feature.properties.length)],
|
||||
["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)],
|
||||
["状态", formatFeaturePropertyValue("status", feature.properties.status)]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
["高程", formatFeaturePropertyValue("elevation", feature.properties.elevation)],
|
||||
["需水量", formatFeaturePropertyValue("demand", feature.properties.demand)],
|
||||
["对象类型", "管网节点"],
|
||||
["状态", "在线"]
|
||||
];
|
||||
}, [feature]);
|
||||
|
||||
const propertyEntries = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getLocalizedFeatureProperties(feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-auto flex h-full min-h-0 flex-col rounded border border-white/60 bg-white/88 shadow-glass backdrop-blur">
|
||||
<div className="border-b border-slate-200/80 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.14em] text-slate-500">要素详情</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
||||
{feature ? feature.title : "选择一个管网对象"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭详情"
|
||||
title="关闭详情"
|
||||
onClick={onClose}
|
||||
className="grid h-9 w-9 place-items-center rounded border border-slate-200 bg-white text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{feature ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{metrics.map(([label, value]) => (
|
||||
<div key={label} className="rounded border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||
<dl className="mt-2 divide-y divide-slate-100 rounded border border-slate-200 bg-white">
|
||||
{propertyEntries.map((entry) => (
|
||||
<div key={entry.key} className="grid grid-cols-[110px_1fr] gap-2 px-3 py-2 text-sm">
|
||||
<dt className="truncate text-slate-500">{entry.label}</dt>
|
||||
<dd className="truncate font-medium text-slate-800">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="grid h-12 w-12 place-items-center rounded border border-slate-200 bg-white text-slate-500">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
|
||||
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { getLocalizedFeatureProperties } from "../utils/feature-properties";
|
||||
|
||||
type FeaturePopoverProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = getLocalizedFeatureProperties(feature.properties).slice(0, 4);
|
||||
|
||||
return (
|
||||
<aside className={cn("pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-32px))] -translate-x-1/2 border border-white/60 bg-white/70 p-4 shadow-2xl shadow-blue-950/20 backdrop-blur-2xl", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-blue-600">{feature.layer === "pipes" ? "管线要素" : "节点要素"}</p>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||
<p className="mt-1 truncate text-sm text-slate-500">{feature.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭要素信息"
|
||||
title="关闭要素信息"
|
||||
onClick={onClose}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className={cn("bg-white/95 px-3 py-2 shadow-lg shadow-blue-950/10", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<dt className="truncate text-xs font-medium text-slate-500">{entry.label}</dt>
|
||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-800">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||
active
|
||||
? "border-slate-800 bg-slate-900 text-white"
|
||||
: "border-white/60 bg-white/80 text-slate-700 shadow-sm hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Loader2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledWorkOrderItem,
|
||||
ScheduledWorkOrderStage
|
||||
} from "@/features/workbench/types";
|
||||
|
||||
export const CONDITION_FILTER_ALL = "__all__";
|
||||
|
||||
export const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "border-green-100 bg-green-50 text-green-700",
|
||||
running: "border-blue-100 bg-blue-50 text-blue-700",
|
||||
warning: "border-orange-100 bg-orange-50 text-orange-700",
|
||||
error: "border-red-100 bg-red-50 text-red-700",
|
||||
pending: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
};
|
||||
|
||||
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
running: Loader2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
pending: Clock3
|
||||
};
|
||||
|
||||
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "bg-green-50 text-green-700",
|
||||
running: "bg-blue-50 text-blue-700",
|
||||
warning: "bg-orange-50 text-orange-700",
|
||||
error: "bg-red-50 text-red-700",
|
||||
pending: "bg-slate-100 text-slate-600"
|
||||
};
|
||||
|
||||
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
|
||||
|
||||
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
|
||||
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
|
||||
|
||||
export type ConditionTaskFilterOption = {
|
||||
value: ConditionTaskFilterValue;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ConditionStatusFilterOption = {
|
||||
value: ScheduledConditionStatus;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
|
||||
const recentConditions = conditions
|
||||
.filter((condition) => condition.kind === "condition")
|
||||
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
|
||||
|
||||
const futureWorkOrders = conditions
|
||||
.filter((condition) => condition.kind === "work_order")
|
||||
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
|
||||
|
||||
return { recentConditions, futureWorkOrders };
|
||||
}
|
||||
|
||||
export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): ConditionTaskFilterOption[] {
|
||||
const taskCounts = new Map<string, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
taskCounts.set(condition.title, (taskCounts.get(condition.title) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return [...taskCounts.entries()]
|
||||
.map(([label, count]) => ({ value: label, label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "zh-CN"));
|
||||
}
|
||||
|
||||
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
|
||||
const statusCounts = new Map<ScheduledConditionStatus, number>();
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return conditionStatusOrder.map((status) => ({
|
||||
value: status,
|
||||
label: statusLabels[status],
|
||||
count: statusCounts.get(status) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function getConditionTime(condition: ScheduledConditionItem) {
|
||||
return Date.parse(condition.scheduledAt);
|
||||
}
|
||||
|
||||
export function formatTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
|
||||
const startTime = formatTime(value);
|
||||
if (typeof durationMinutes !== "number") {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
|
||||
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
|
||||
if (status === "running") {
|
||||
return "执行中";
|
||||
}
|
||||
|
||||
if (typeof durationMinutes !== "number") {
|
||||
return "未记录";
|
||||
}
|
||||
|
||||
return `${durationMinutes} 分钟`;
|
||||
}
|
||||
|
||||
export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "高风险";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "关注";
|
||||
}
|
||||
|
||||
return "正常";
|
||||
}
|
||||
|
||||
export function getKpiStatusClassName(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "border-red-100 bg-red-50 text-red-700";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "border-orange-100 bg-orange-50 text-orange-700";
|
||||
}
|
||||
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
|
||||
return (
|
||||
condition.stages.find((stage) => stage.status === "current") ??
|
||||
condition.stages.find((stage) => stage.status === "pending") ??
|
||||
condition.stages.at(-1) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
|
||||
if (stageId === "execution") {
|
||||
return "border-blue-100 bg-blue-50 text-blue-700";
|
||||
}
|
||||
|
||||
if (stageId === "reply") {
|
||||
return "border-green-100 bg-green-50 text-green-700";
|
||||
}
|
||||
|
||||
return "border-indigo-100 bg-indigo-50 text-indigo-700";
|
||||
}
|
||||
|
||||
export function getDetailAccentClassName(status: ScheduledConditionStatus, isWorkOrder: boolean) {
|
||||
if (isWorkOrder) {
|
||||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
if (status === "running") {
|
||||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
if (status === "warning") {
|
||||
return "bg-orange-500";
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
return "bg-slate-400";
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: ScheduledConditionStatus) {
|
||||
return statusIcons[status];
|
||||
}
|
||||
|
||||
export function getStatusIconClassName(status: ScheduledConditionStatus) {
|
||||
return statusIconClassNames[status];
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
||||
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||
import {
|
||||
CONDITION_FILTER_ALL,
|
||||
createStatusFilterOptions,
|
||||
createTaskFilterOptions,
|
||||
formatTime,
|
||||
getConditionTime,
|
||||
getStatusIcon,
|
||||
getStatusIconClassName,
|
||||
groupScheduledConditions,
|
||||
type ConditionStatusFilterOption,
|
||||
type ConditionStatusFilterValue,
|
||||
type ConditionTaskFilterOption,
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
loading?: boolean;
|
||||
selectedConditionId?: string | null;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ScheduledConditionFeedFocusRequest = {
|
||||
conditionId: string;
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
loading = false,
|
||||
selectedConditionId: controlledSelectedConditionId,
|
||||
onExpandedChange,
|
||||
onFocusRequestHandled,
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(conditions),
|
||||
[conditions]
|
||||
);
|
||||
const allTimelineConditions = useMemo(
|
||||
() => [...allRecentConditions, ...allFutureWorkOrders],
|
||||
[allFutureWorkOrders, allRecentConditions]
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
taskFilteredConditions.filter(
|
||||
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
);
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
||||
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
||||
const headerSummary = loading
|
||||
? "正在加载工况任务"
|
||||
: expanded && filterActive
|
||||
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
||||
: expanded
|
||||
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
||||
: `最近 ${recentConditions.length} 条`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
recentConditions[0] ??
|
||||
futureWorkOrders[0] ??
|
||||
null;
|
||||
const focusConditionId = focusRequest?.conditionId;
|
||||
const focusRequestId = focusRequest?.requestId;
|
||||
const updateExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (controlledExpanded === undefined) {
|
||||
setUncontrolledExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[controlledExpanded, onExpandedChange]
|
||||
);
|
||||
const updateSelectedConditionId = useCallback(
|
||||
(nextConditionId: string | null) => {
|
||||
if (controlledSelectedConditionId === undefined) {
|
||||
setUncontrolledSelectedConditionId(nextConditionId);
|
||||
}
|
||||
|
||||
onSelectedConditionChange?.(nextConditionId);
|
||||
},
|
||||
[controlledSelectedConditionId, onSelectedConditionChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskFilter === CONDITION_FILTER_ALL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
}
|
||||
}, [taskFilter, taskFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelectedConditionId = selectedCondition?.id ?? null;
|
||||
if (selectedConditionId !== nextSelectedConditionId) {
|
||||
updateSelectedConditionId(nextSelectedConditionId);
|
||||
}
|
||||
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusConditionId || focusRequestId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
updateSelectedConditionId(conditionId);
|
||||
if (!expanded) {
|
||||
updateExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContinueConversation(condition: ScheduledConditionItem) {
|
||||
if (condition.kind === "work_order") {
|
||||
return;
|
||||
}
|
||||
|
||||
onExpandAgent();
|
||||
|
||||
if (!condition.sessionId) {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-missing-session",
|
||||
tone: "error",
|
||||
title: "无法打开历史会话",
|
||||
message: "这条工况没有可加载的会话记录。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
|
||||
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
|
||||
.then(() => {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "success",
|
||||
title: "已发起工况对话",
|
||||
message: "已将当前工况上下文发送到 Agent 面板。"
|
||||
});
|
||||
})
|
||||
.catch((submitError) => {
|
||||
showMapNotice({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(onLoadHistorySession(condition.sessionId));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl",
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={handleToggleExpanded}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
/>
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
{loading && expanded ? (
|
||||
<ConditionDetailSkeleton />
|
||||
) : expanded && selectedCondition ? (
|
||||
<ConditionDetailPanel
|
||||
condition={selectedCondition}
|
||||
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
taskFilter={taskFilter}
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
onTaskFilterChange={setTaskFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onResetFilters={() => {
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
}}
|
||||
onSelectCondition={handleSelectTimelineCondition}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
taskFilter: ConditionTaskFilterValue;
|
||||
statusFilter: ConditionStatusFilterValue;
|
||||
taskFilterOptions: ConditionTaskFilterOption[];
|
||||
statusFilterOptions: ConditionStatusFilterOption[];
|
||||
selectedConditionId: string | null;
|
||||
totalConditionCount: number;
|
||||
taskFilteredConditionCount: number;
|
||||
filteredConditionCount: number;
|
||||
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||
onResetFilters: () => void;
|
||||
onSelectCondition: (conditionId: string) => void;
|
||||
};
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
taskFilter,
|
||||
statusFilter,
|
||||
taskFilterOptions,
|
||||
statusFilterOptions,
|
||||
selectedConditionId,
|
||||
totalConditionCount,
|
||||
taskFilteredConditionCount,
|
||||
filteredConditionCount,
|
||||
onTaskFilterChange,
|
||||
onStatusFilterChange,
|
||||
onResetFilters,
|
||||
onSelectCondition
|
||||
}: ConditionTimelinePanelProps) {
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
"rounded-xl",
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{expanded && !loading ? (
|
||||
<ConditionFilterBar
|
||||
taskFilter={taskFilter}
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
onTaskFilterChange={onTaskFilterChange}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onSelectCondition={onSelectCondition}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
) : recentConditions.length > 0 ? (
|
||||
<ConditionRows
|
||||
conditions={recentConditions}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onSelectCondition={onSelectCondition}
|
||||
/>
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionFilterBarProps = {
|
||||
taskFilter: ConditionTaskFilterValue;
|
||||
statusFilter: ConditionStatusFilterValue;
|
||||
taskFilterOptions: ConditionTaskFilterOption[];
|
||||
statusFilterOptions: ConditionStatusFilterOption[];
|
||||
filteredConditionCount: number;
|
||||
totalConditionCount: number;
|
||||
taskFilteredConditionCount: number;
|
||||
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
||||
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
||||
onResetFilters: () => void;
|
||||
};
|
||||
|
||||
function ConditionFilterBar({
|
||||
taskFilter,
|
||||
statusFilter,
|
||||
taskFilterOptions,
|
||||
statusFilterOptions,
|
||||
filteredConditionCount,
|
||||
totalConditionCount,
|
||||
taskFilteredConditionCount,
|
||||
onTaskFilterChange,
|
||||
onStatusFilterChange,
|
||||
onResetFilters
|
||||
}: ConditionFilterBarProps) {
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
{filteredConditionCount}/{totalConditionCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ConditionFilterDropdown
|
||||
label="任务"
|
||||
value={taskFilter}
|
||||
onValueChange={onTaskFilterChange}
|
||||
options={[
|
||||
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
|
||||
...taskFilterOptions
|
||||
]}
|
||||
/>
|
||||
<ConditionFilterDropdown
|
||||
label="状态"
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
|
||||
options={[
|
||||
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
|
||||
...statusFilterOptions
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionFilterDropdownProps<TValue extends string> = {
|
||||
label: string;
|
||||
value: TValue;
|
||||
options: {
|
||||
value: TValue;
|
||||
label: string;
|
||||
count: number;
|
||||
}[];
|
||||
onValueChange: (value: TValue) => void;
|
||||
};
|
||||
|
||||
function ConditionFilterDropdown<TValue extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange
|
||||
}: ConditionFilterDropdownProps<TValue>) {
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-white/80 bg-white p-1.5 text-slate-700 shadow-xl shadow-slate-900/15 backdrop-blur-xl"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={cn(
|
||||
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
|
||||
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||
{option.count}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-white/65 px-4 py-5 text-center">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
|
||||
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
return (
|
||||
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
|
||||
>
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
<span className="min-w-0 rounded-xl border border-white/80 bg-white/95 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 space-y-1.5">
|
||||
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
|
||||
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
|
||||
</span>
|
||||
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionDetailSkeleton() {
|
||||
return (
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-white/95 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
|
||||
<div className="flex gap-2.5">
|
||||
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
|
||||
</div>
|
||||
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3">
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DetailScroll>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailScroll({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionGroupHeaderProps = {
|
||||
icon: typeof History;
|
||||
title: string;
|
||||
count: number;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionRowsProps = {
|
||||
conditions: ScheduledConditionItem[];
|
||||
selectedConditionId: string | null;
|
||||
onSelectCondition: (conditionId: string) => void;
|
||||
};
|
||||
|
||||
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{conditions.map((condition, index) => (
|
||||
<ConditionTimelineRow
|
||||
key={condition.id}
|
||||
condition={condition}
|
||||
selected={selectedConditionId === condition.id}
|
||||
isLast={index === conditions.length - 1}
|
||||
onSelect={() => onSelectCondition(condition.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionTimelineRowProps = {
|
||||
condition: ScheduledConditionItem;
|
||||
selected: boolean;
|
||||
isLast: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
|
||||
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
||||
selected
|
||||
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
||||
: isWorkOrder
|
||||
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
||||
: getStatusIconClassName(condition.status)
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
|
||||
selected
|
||||
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
|
||||
: "border-white/80 bg-white/95 text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
Activity,
|
||||
Camera,
|
||||
Circle,
|
||||
Copy,
|
||||
Database,
|
||||
Dot,
|
||||
Download,
|
||||
Keyboard,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
Pentagon,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Ruler,
|
||||
Share2,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Waypoints,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
MapAnnotationPanel,
|
||||
type MapAnnotationItem,
|
||||
type MapAnnotationShareAction
|
||||
} from "@/features/map/core/components/annotation-panel";
|
||||
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapControlPanel,
|
||||
MapMetricTile,
|
||||
MapPanelSection
|
||||
} from "@/features/map/core/components/control-panel";
|
||||
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
|
||||
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
|
||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
||||
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
|
||||
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
|
||||
import {
|
||||
type WorkbenchDrawingAnnotation,
|
||||
type WorkbenchDrawMode
|
||||
} from "../hooks/use-workbench-drawing";
|
||||
import {
|
||||
type WorkbenchMeasurementResult,
|
||||
type WorkbenchMeasureMode,
|
||||
type WorkbenchMeasureUnit
|
||||
} from "../hooks/use-workbench-measurement";
|
||||
import { waterNetworkToolbarItems } from "../map/toolbar-config";
|
||||
|
||||
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
|
||||
export type ExportViewPreset = "current" | "4k";
|
||||
|
||||
type MeasureModeOption = {
|
||||
id: WorkbenchMeasureMode;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const MEASURE_MODES: MeasureModeOption[] = [
|
||||
{ id: "distance", label: "距离", icon: Route },
|
||||
{ id: "area", label: "面积", icon: Pentagon },
|
||||
{ id: "segment", label: "管段", icon: Waypoints }
|
||||
];
|
||||
|
||||
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
|
||||
{ id: "m", label: "m" },
|
||||
{ id: "km", label: "km" }
|
||||
];
|
||||
|
||||
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
|
||||
{
|
||||
id: "export",
|
||||
icon: Share2,
|
||||
label: "导出标注",
|
||||
description: "GeoJSON / 截图报告"
|
||||
}
|
||||
];
|
||||
|
||||
const TOOL_PANEL_EXIT_MS = 160;
|
||||
|
||||
const DRAW_TOOL_OPTIONS: Array<{
|
||||
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
tone?: "danger";
|
||||
}> = [
|
||||
{ id: "point", label: "点标注", icon: Dot },
|
||||
{ id: "line", label: "线标注", icon: Route },
|
||||
{ id: "polygon", label: "范围标注", icon: Pentagon },
|
||||
{ id: "circle", label: "圆形标注", icon: Circle },
|
||||
{ id: "undo", label: "撤销", icon: Undo2 },
|
||||
{ id: "redo", label: "重做", icon: Redo2 },
|
||||
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
|
||||
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
|
||||
];
|
||||
|
||||
export type ToolbarPanelProps = {
|
||||
activeToolId: ToolbarToolId | null;
|
||||
activeMeasureModeId: WorkbenchMeasureMode;
|
||||
activeMeasureUnitId: WorkbenchMeasureUnit;
|
||||
measuring: boolean;
|
||||
measureResult: WorkbenchMeasurementResult;
|
||||
measureEnabled: boolean;
|
||||
hasMeasurePoints: boolean;
|
||||
activeDrawToolId: WorkbenchDrawMode | null;
|
||||
drawingEnabled: boolean;
|
||||
drawingAnnotations: WorkbenchDrawingAnnotation[];
|
||||
canUndoDrawing: boolean;
|
||||
canRedoDrawing: boolean;
|
||||
canDeleteSelectedDrawing: boolean;
|
||||
hasDrawingFeatures: boolean;
|
||||
layerItems: MapLayerControlItem[];
|
||||
legendItems: MapLegendItem[];
|
||||
baseLayerOptions: BaseLayerOption[];
|
||||
activeBaseLayerId: string;
|
||||
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
|
||||
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
|
||||
onStartMeasure: () => void;
|
||||
onStopMeasure: () => void;
|
||||
onClearMeasure: () => void;
|
||||
onCopyMeasure: () => void;
|
||||
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
|
||||
onUndoDrawing: () => void;
|
||||
onRedoDrawing: () => void;
|
||||
onDeleteSelectedDrawing: () => void;
|
||||
onClearDrawing: () => void;
|
||||
onExportAnnotations: () => void;
|
||||
onSelectBaseLayer: (id: string) => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
panelWidthClassName?: string;
|
||||
};
|
||||
|
||||
export function ToolbarPanel({
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId,
|
||||
measuring,
|
||||
measureResult,
|
||||
measureEnabled,
|
||||
hasMeasurePoints,
|
||||
activeDrawToolId,
|
||||
drawingEnabled,
|
||||
drawingAnnotations,
|
||||
canUndoDrawing,
|
||||
canRedoDrawing,
|
||||
canDeleteSelectedDrawing,
|
||||
hasDrawingFeatures,
|
||||
layerItems,
|
||||
legendItems,
|
||||
baseLayerOptions,
|
||||
activeBaseLayerId,
|
||||
onSelectMeasureMode,
|
||||
onSelectMeasureUnit,
|
||||
onStartMeasure,
|
||||
onStopMeasure,
|
||||
onClearMeasure,
|
||||
onCopyMeasure,
|
||||
onSelectDrawTool,
|
||||
onUndoDrawing,
|
||||
onRedoDrawing,
|
||||
onDeleteSelectedDrawing,
|
||||
onClearDrawing,
|
||||
onExportAnnotations,
|
||||
onSelectBaseLayer,
|
||||
onToggleLayer,
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig,
|
||||
panelWidthClassName
|
||||
}: ToolbarPanelProps) {
|
||||
const annotationItems = useMemo<MapAnnotationItem[]>(
|
||||
() =>
|
||||
drawingAnnotations.map((annotation) => ({
|
||||
id: annotation.id,
|
||||
icon: getDrawingAnnotationIcon(annotation.kind),
|
||||
label: annotation.label,
|
||||
description: annotation.description,
|
||||
status: annotation.hidden ? "隐藏" : "显示",
|
||||
selected: annotation.selected,
|
||||
muted: annotation.hidden,
|
||||
onClick: annotation.onToggleVisibility
|
||||
})),
|
||||
[drawingAnnotations]
|
||||
);
|
||||
const annotationShareActions = useMemo(
|
||||
() =>
|
||||
ANNOTATION_SHARE_ACTIONS.map((action) => ({
|
||||
...action,
|
||||
onClick: action.id === "export" ? onExportAnnotations : action.onClick
|
||||
})),
|
||||
[onExportAnnotations]
|
||||
);
|
||||
|
||||
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
|
||||
const panelVisible = Boolean(activeToolId);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeToolId) {
|
||||
setRenderedToolId(activeToolId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderedToolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exitTimer = window.setTimeout(() => {
|
||||
setRenderedToolId(null);
|
||||
}, TOOL_PANEL_EXIT_MS);
|
||||
|
||||
return () => window.clearTimeout(exitTimer);
|
||||
}, [activeToolId, renderedToolId]);
|
||||
|
||||
if (!renderedToolId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
|
||||
const Icon = activeTool?.icon ?? Layers3;
|
||||
|
||||
if (renderedToolId === "layers") {
|
||||
return (
|
||||
<MapControlPanel
|
||||
key="layers"
|
||||
title="图层"
|
||||
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
|
||||
icon={Icon}
|
||||
widthClassName={panelWidthClassName}
|
||||
visible={panelVisible}
|
||||
>
|
||||
<MapLayerPanel
|
||||
layerItems={layerItems}
|
||||
baseLayerOptions={baseLayerOptions}
|
||||
activeBaseLayerId={activeBaseLayerId}
|
||||
onSelectBaseLayer={onSelectBaseLayer}
|
||||
onToggleLayer={onToggleLayer}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "measure") {
|
||||
return (
|
||||
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||
<MapMeasurePanel
|
||||
modes={MEASURE_MODES}
|
||||
activeModeId={activeMeasureModeId}
|
||||
resultLabel={measureResult.label}
|
||||
resultValue={measureResult.value}
|
||||
resultUnit={measureResult.unit}
|
||||
metrics={measureResult.metrics}
|
||||
units={MEASURE_UNITS}
|
||||
activeUnitId={activeMeasureUnitId}
|
||||
actions={[
|
||||
{
|
||||
id: measuring ? "stop" : "start",
|
||||
icon: Ruler,
|
||||
label: measuring ? "停止测量" : "开始测量",
|
||||
description:
|
||||
activeMeasureModeId === "segment"
|
||||
? measuring
|
||||
? "暂停管线选择"
|
||||
: "点击管线选择资产"
|
||||
: measuring
|
||||
? "暂停地图取点"
|
||||
: "在地图上连续点击取点",
|
||||
variant: measuring ? "default" : "primary",
|
||||
disabled: !measureEnabled,
|
||||
onClick: measuring ? onStopMeasure : onStartMeasure
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
icon: Copy,
|
||||
label: "复制结果",
|
||||
description: "复制当前读数",
|
||||
disabled: !hasMeasurePoints,
|
||||
onClick: onCopyMeasure
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
icon: Trash2,
|
||||
label: "清除测量",
|
||||
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
|
||||
tone: "danger",
|
||||
disabled: !hasMeasurePoints,
|
||||
onClick: onClearMeasure
|
||||
}
|
||||
]}
|
||||
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
|
||||
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "analysis") {
|
||||
return (
|
||||
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
|
||||
<AnalysisPanel legendItems={legendItems} />
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedToolId === "annotation") {
|
||||
return (
|
||||
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
|
||||
<MapPanelSection title="绘制">
|
||||
<MapDrawToolbar
|
||||
variant="panel"
|
||||
items={DRAW_TOOL_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
active: activeDrawToolId === item.id,
|
||||
disabled:
|
||||
!drawingEnabled ||
|
||||
(item.id === "undo" && !canUndoDrawing) ||
|
||||
(item.id === "redo" && !canRedoDrawing) ||
|
||||
(item.id === "delete" && !canDeleteSelectedDrawing) ||
|
||||
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
|
||||
onClick: () => {
|
||||
if (item.id === "undo") {
|
||||
onUndoDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "redo") {
|
||||
onRedoDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "delete") {
|
||||
onDeleteSelectedDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id === "clear") {
|
||||
onClearDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
</MapPanelSection>
|
||||
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
|
||||
<MorePanel
|
||||
onExportView={onExportView}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
/>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="图例">
|
||||
<MapLegendList items={legendItems} showStatusDot />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="当前覆盖">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MapMetricTile label="影响范围" value="未开启" />
|
||||
<MapMetricTile label="风险预览" value="待生成" />
|
||||
</div>
|
||||
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
|
||||
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MorePanelProps = {
|
||||
onExportView: (preset: ExportViewPreset) => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
function MorePanel({
|
||||
onExportView,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: MorePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="导出视图">
|
||||
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
|
||||
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="数据">
|
||||
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
|
||||
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="系统">
|
||||
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
|
||||
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
|
||||
if (kind === "line") {
|
||||
return Route;
|
||||
}
|
||||
|
||||
if (kind === "polygon") {
|
||||
return Pentagon;
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
return Circle;
|
||||
}
|
||||
|
||||
return MapPinned;
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
|
||||
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
active: "运行中",
|
||||
draft: "草案",
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"border border-white/80 bg-white/95 p-2 text-slate-900 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 backdrop-blur-xl"
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
"bg-white/80 focus:bg-blue-50/95 focus:text-slate-950";
|
||||
const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
export function ScenarioMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
activeScenarioName,
|
||||
onSelectScenario,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
scenarios: WorkbenchScenario[];
|
||||
activeScenarioId: string;
|
||||
activeScenarioName: string;
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
|
||||
aria-label="切换模拟方案"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="truncate">场景 · {activeScenarioName}</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
||||
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
|
||||
<MenuSectionLabel>场景列表</MenuSectionLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeScenarioId}
|
||||
onValueChange={(nextScenarioId) => {
|
||||
onSelectScenario(nextScenarioId);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{scenarios.map((scenario) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={scenario.id}
|
||||
value={scenario.id}
|
||||
className={cn(
|
||||
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
|
||||
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<MenuSeparator />
|
||||
<MenuSectionLabel>场景命令</MenuSectionLabel>
|
||||
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
|
||||
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
|
||||
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
alerts,
|
||||
compact = false,
|
||||
conditionFeedVisible,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onAnalyzeAlertsWithAgent
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alerts: WorkbenchAlert[];
|
||||
compact?: boolean;
|
||||
conditionFeedVisible: boolean;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
}) {
|
||||
const hasAlerts = alerts.length > 0;
|
||||
const latestAlertTime = alerts[0]?.time;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
|
||||
compact && "h-10 w-10 justify-center px-0"
|
||||
)}
|
||||
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts &&
|
||||
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compact ? "sr-only" : undefined}>异常处置</span>
|
||||
{compact ? (
|
||||
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
{alerts.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
|
||||
{alerts.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
|
||||
<AlertQueueSummary
|
||||
count={alerts.length}
|
||||
latestAlertTime={latestAlertTime}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
/>
|
||||
<MenuSectionLabel>待复核工况</MenuSectionLabel>
|
||||
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
||||
{hasAlerts ? alerts.map((alert) => (
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={() => onSelectAlert(alert)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-white/70 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2">
|
||||
<MenuPlainButton
|
||||
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
|
||||
description="查看时间线与处置详情"
|
||||
disabled={conditionFeedVisible}
|
||||
onClick={() => {
|
||||
onToggleConditionFeed();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
<MenuPrimaryButton
|
||||
disabled={!hasAlerts}
|
||||
icon={Bot}
|
||||
label={hasAlerts ? "工况汇总" : "等待提醒"}
|
||||
onClick={() => {
|
||||
void onAnalyzeAlertsWithAgent();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: WorkbenchUser;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
aria-label="打开用户菜单"
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<UserRound size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="hidden text-sm font-semibold md:inline">{user.name}</span>
|
||||
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 sm:block" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<MenuSeparator />
|
||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertQueueSummary({
|
||||
count,
|
||||
latestAlertTime,
|
||||
conditionFeedVisible
|
||||
}: {
|
||||
count: number;
|
||||
latestAlertTime?: string;
|
||||
conditionFeedVisible: boolean;
|
||||
}) {
|
||||
const hasAlerts = count > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("overflow-hidden bg-white/90 px-3 py-3 ring-1 ring-white/80", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
|
||||
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
|
||||
)}
|
||||
>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
|
||||
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuPlainButton({
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-slate-200 bg-slate-50 text-slate-500"
|
||||
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-100"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-xs font-semibold leading-4">{label}</span>
|
||||
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuPrimaryButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-slate-200 bg-slate-50 text-slate-400"
|
||||
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="max-w-[92px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuHeader({
|
||||
title,
|
||||
meta,
|
||||
tone = "default",
|
||||
icon: Icon
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
tone?: "default" | "warning";
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between gap-3 border border-white/70 bg-white/90 px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
|
||||
</div>
|
||||
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSectionLabel({ children }: { children: ReactNode }) {
|
||||
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>;
|
||||
}
|
||||
|
||||
function MenuSeparator() {
|
||||
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />;
|
||||
}
|
||||
|
||||
function MenuAction({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
tone = "default",
|
||||
onSelect
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
tone?: "default" | "primary" | "warning";
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"my-0.5 items-center border border-transparent px-2 py-2 transition",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
|
||||
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
|
||||
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
Droplets,
|
||||
Eye,
|
||||
EyeOff,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
|
||||
export type WorkbenchTopBarProps = {
|
||||
dataTime: string;
|
||||
modelName: string;
|
||||
scenarios: WorkbenchScenario[];
|
||||
activeScenarioId: string;
|
||||
alerts: WorkbenchAlert[];
|
||||
user: WorkbenchUser;
|
||||
conditionFeedVisible: boolean;
|
||||
taskTickerAvailable: boolean;
|
||||
taskTickerVisible: boolean;
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onToggleTaskTicker: () => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
};
|
||||
|
||||
const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
const headerControlIconClassName =
|
||||
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
||||
|
||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||
|
||||
export function WorkbenchTopBar({
|
||||
dataTime,
|
||||
modelName,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
alerts,
|
||||
user,
|
||||
conditionFeedVisible,
|
||||
taskTickerAvailable,
|
||||
taskTickerVisible,
|
||||
onSelectScenario,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onToggleTaskTicker,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport,
|
||||
onAnalyzeAlertsWithAgent,
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b border-white/65 bg-white/80 px-3 shadow-lg shadow-slate-900/5 backdrop-blur-2xl sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">排水管网</h1>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 text-sm text-slate-700 lg:flex xl:gap-4">
|
||||
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
||||
<HeaderDivider />
|
||||
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
||||
<HeaderDivider />
|
||||
<TaskTickerToggle
|
||||
available={taskTickerAvailable}
|
||||
visible={taskTickerVisible}
|
||||
onToggle={onToggleTaskTicker}
|
||||
/>
|
||||
{taskTickerAvailable ? <HeaderDivider /> : null}
|
||||
<ConditionFeedToggle visible={conditionFeedVisible} onToggle={onToggleConditionFeed} />
|
||||
<HeaderDivider />
|
||||
<AlertMenu
|
||||
open={openMenu === "alerts"}
|
||||
onOpenChange={createMenuOpenChange("alerts")}
|
||||
alerts={alerts}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
<HeaderDivider />
|
||||
{activeScenario ? (
|
||||
<ScenarioMenu
|
||||
open={openMenu === "scenario"}
|
||||
onOpenChange={createMenuOpenChange("scenario")}
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenario.id}
|
||||
activeScenarioName={activeScenario.name}
|
||||
onSelectScenario={onSelectScenario}
|
||||
onPreviewScenario={onPreviewScenario}
|
||||
onCompareScenario={onCompareScenario}
|
||||
onExportScenarioReport={onExportScenarioReport}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
open={openMenu === "compact-alerts"}
|
||||
onOpenChange={createMenuOpenChange("compact-alerts")}
|
||||
alerts={alerts}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
</div>
|
||||
<UserMenu
|
||||
open={openMenu === "user"}
|
||||
onOpenChange={createMenuOpenChange("user")}
|
||||
user={user}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTickerToggle({
|
||||
available,
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
available: boolean;
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = visible ? EyeOff : Eye;
|
||||
|
||||
if (!available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
aria-pressed={visible}
|
||||
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span>任务浮条</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={visible}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
headerControlButtonClassName,
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span>工况任务</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="text-xs font-medium text-slate-500">{label}</span>
|
||||
<span className="truncate text-sm font-semibold text-slate-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderDivider() {
|
||||
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
ScheduledConditionRecord,
|
||||
ScheduledConditionStatus,
|
||||
ScheduledConditionTaskId
|
||||
} from "@/features/workbench/types";
|
||||
|
||||
export type RunningWorkflowStepDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type RunningWorkflowDefinition = {
|
||||
title: string;
|
||||
reportLabel: string;
|
||||
steps: RunningWorkflowStepDefinition[];
|
||||
};
|
||||
|
||||
export type RunningWorkflowStepStatus = "done" | "current" | "pending";
|
||||
|
||||
export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, RunningWorkflowDefinition> = {
|
||||
"scada-diagnosis": {
|
||||
title: "SCADA 诊断流程",
|
||||
reportLabel: "生成诊断报告",
|
||||
steps: [
|
||||
{
|
||||
id: "ingest-telemetry",
|
||||
title: "读取 SCADA 遥测",
|
||||
detail: "拉取压力、流量和关键阀门测点,校验时间戳与回传频率。"
|
||||
},
|
||||
{
|
||||
id: "freshness-check",
|
||||
title: "检查新鲜度完整率",
|
||||
detail: "按测点组计算缺测、延迟和连续异常样本比例。"
|
||||
},
|
||||
{
|
||||
id: "consistency-check",
|
||||
title: "压流一致性诊断",
|
||||
detail: "比对压力变化、流量变化和相邻测点趋势是否符合水力逻辑。"
|
||||
},
|
||||
{
|
||||
id: "isolate-sources",
|
||||
title: "标记异常数据源",
|
||||
detail: "圈定疑似通信异常或越界测点,给后续模拟和调度提供屏蔽建议。"
|
||||
},
|
||||
{
|
||||
id: "diagnosis-report",
|
||||
title: "整理诊断结论",
|
||||
detail: "汇总数据质量、异常证据和是否允许进入后续工况任务。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"smart-dispatch": {
|
||||
title: "智能调度流程",
|
||||
reportLabel: "生成调度指令",
|
||||
steps: [
|
||||
{
|
||||
id: "read-condition",
|
||||
title: "读取实时工况",
|
||||
detail: "汇总目标分区压力、流量、泵站出水和阀门开度。"
|
||||
},
|
||||
{
|
||||
id: "candidate-actions",
|
||||
title: "生成候选动作",
|
||||
detail: "枚举泵站目标压力、阀门边界和高频复核测点的可执行动作。"
|
||||
},
|
||||
{
|
||||
id: "simulate-impact",
|
||||
title: "模拟影响范围",
|
||||
detail: "预估指令执行后的压力改善、分区压差和关键用户影响。"
|
||||
},
|
||||
{
|
||||
id: "rank-actions",
|
||||
title: "排序调度方案",
|
||||
detail: "按改善幅度、操作代价和数据可信度筛选建议指令。"
|
||||
},
|
||||
{
|
||||
id: "dispatch-draft",
|
||||
title: "形成复核清单",
|
||||
detail: "输出待调度员确认的管网要素调整指令和复核条件。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"network-simulation": {
|
||||
title: "定时模拟流程",
|
||||
reportLabel: "生成模拟报告",
|
||||
steps: [
|
||||
{
|
||||
id: "sync-boundary",
|
||||
title: "同步模型边界",
|
||||
detail: "读取最新泵站启停、阀门开度、需水量和 SCADA 边界条件。"
|
||||
},
|
||||
{
|
||||
id: "run-hydraulic-model",
|
||||
title: "运行水力模型",
|
||||
detail: "滚动计算全网节点压力、管段流量和分区边界平衡。"
|
||||
},
|
||||
{
|
||||
id: "compare-telemetry",
|
||||
title: "比对实测偏差",
|
||||
detail: "将模拟压力、流量与在线测点回传值进行偏差校验。"
|
||||
},
|
||||
{
|
||||
id: "locate-deviation",
|
||||
title: "定位偏差来源",
|
||||
detail: "识别模型参数、边界条件或遥测源导致的主要偏差区域。"
|
||||
},
|
||||
{
|
||||
id: "simulation-report",
|
||||
title: "输出模型复核",
|
||||
detail: "给出可信度、关注指标和是否允许用于调度决策。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pump-energy": {
|
||||
title: "泵站能耗流程",
|
||||
reportLabel: "生成能耗报告",
|
||||
steps: [
|
||||
{
|
||||
id: "read-pump-load",
|
||||
title: "读取泵组负载",
|
||||
detail: "采集泵站频率、电流、出水压力、出水量和启停记录。"
|
||||
},
|
||||
{
|
||||
id: "calculate-energy",
|
||||
title: "计算单位电耗",
|
||||
detail: "按当前窗口排水量折算单位排水电耗和效率偏差。"
|
||||
},
|
||||
{
|
||||
id: "compare-efficiency",
|
||||
title: "比对经济区间",
|
||||
detail: "结合泵组效率曲线判断当前组合是否处于低效并联区间。"
|
||||
},
|
||||
{
|
||||
id: "pressure-guard",
|
||||
title: "校验供压约束",
|
||||
detail: "复核节能建议不会引发末端低压或分区边界异常。"
|
||||
},
|
||||
{
|
||||
id: "energy-advice",
|
||||
title: "形成节能建议",
|
||||
detail: "输出启停优化、压力设定和继续观察条件。"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
|
||||
return runningWorkflowDefinitions[condition.taskId];
|
||||
}
|
||||
|
||||
export function getRunningWorkflowState(
|
||||
condition: ScheduledConditionRecord,
|
||||
workflow: RunningWorkflowDefinition,
|
||||
nowMs: number
|
||||
) {
|
||||
if (condition.status !== "running") {
|
||||
return { currentStepIndex: workflow.steps.length - 1, progress: getCompletedProgress(condition.status) };
|
||||
}
|
||||
|
||||
const scheduledAtMs = Date.parse(condition.scheduledAt);
|
||||
const startedAtMs = Number.isFinite(scheduledAtMs) ? scheduledAtMs : nowMs;
|
||||
const durationMs = Math.max(condition.durationMinutes ?? 1, 1) * 60_000;
|
||||
const elapsedMs = Math.max(0, nowMs - startedAtMs);
|
||||
const rawRatio = elapsedMs / durationMs;
|
||||
const runningRatio = Math.min(Math.max(rawRatio, 0), 0.999);
|
||||
const currentStepIndex = Math.min(
|
||||
workflow.steps.length - 1,
|
||||
Math.floor(runningRatio * workflow.steps.length)
|
||||
);
|
||||
const progress = Math.min(99, Math.max(0, Math.floor(runningRatio * 100)));
|
||||
|
||||
return { currentStepIndex, progress };
|
||||
}
|
||||
|
||||
export function getWorkflowStepStatus(
|
||||
condition: ScheduledConditionRecord,
|
||||
stepIndex: number,
|
||||
currentStepIndex: number
|
||||
): RunningWorkflowStepStatus {
|
||||
if (condition.status !== "running") {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (stepIndex < currentStepIndex) {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (stepIndex === currentStepIndex) {
|
||||
return "current";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function getCompletedProgress(status: ScheduledConditionStatus) {
|
||||
if (status === "error") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createOperationalScheduledConditions } from "./scheduled-conditions";
|
||||
|
||||
describe("createOperationalScheduledConditions", () => {
|
||||
it("keeps a scheduled condition running during the previous gap window", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:43:30+08:00"));
|
||||
const runningCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.status === "running"
|
||||
);
|
||||
|
||||
expect(runningCondition?.title).toBe("SCADA 数据诊断");
|
||||
expect(runningCondition?.durationMinutes).toBe(5);
|
||||
expect(runningCondition?.scheduledAt).toContain("T11:40:");
|
||||
});
|
||||
|
||||
it("rolls the running condition into history when the next interval starts", () => {
|
||||
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:45:30+08:00"));
|
||||
const previousCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071140")
|
||||
);
|
||||
const currentCondition = conditions.find(
|
||||
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071145")
|
||||
);
|
||||
|
||||
expect(previousCondition?.status).not.toBe("running");
|
||||
expect(previousCondition?.durationMinutes).toBe(5);
|
||||
expect(currentCondition?.status).toBe("running");
|
||||
expect(currentCondition?.durationMinutes).toBe(5);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import type { WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
|
||||
export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
|
||||
{
|
||||
id: "scenario-a",
|
||||
name: "方案 A",
|
||||
description: "关闭事故点上下游阀门,优先保障核心片区压力。",
|
||||
status: "active"
|
||||
},
|
||||
{
|
||||
id: "scenario-b",
|
||||
name: "方案 B",
|
||||
description: "扩大隔离范围,降低抢修期间二次爆管风险。",
|
||||
status: "review"
|
||||
},
|
||||
{
|
||||
id: "scenario-c",
|
||||
name: "夜间保供",
|
||||
description: "按夜间低峰工况调整泵站与分区调度策略。",
|
||||
status: "draft"
|
||||
}
|
||||
];
|
||||
|
||||
export const WORKBENCH_USER: WorkbenchUser = {
|
||||
name: "调度员",
|
||||
role: "排水运行调度中心"
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { MapAnnotation } from "../types";
|
||||
|
||||
export const mapAnnotations: MapAnnotation[] = [
|
||||
{
|
||||
id: "burst",
|
||||
label: "爆管位置",
|
||||
coordinate: [121.5215, 30.9084],
|
||||
kind: "burst"
|
||||
},
|
||||
{
|
||||
id: "dn600",
|
||||
label: "DN600",
|
||||
coordinate: [121.512, 30.9102],
|
||||
kind: "pipe-label"
|
||||
},
|
||||
{
|
||||
id: "east-gate",
|
||||
label: "东直门小区",
|
||||
coordinate: [121.545, 30.951],
|
||||
kind: "district"
|
||||
},
|
||||
{
|
||||
id: "impact",
|
||||
label: "影响范围 约 1.82 km²",
|
||||
coordinate: [121.567, 30.896],
|
||||
kind: "tooltip"
|
||||
}
|
||||
];
|
||||
|
||||
export const impactAreaPolygon = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: "impact-area",
|
||||
label: "影响范围",
|
||||
area: "1.82 km²"
|
||||
},
|
||||
geometry: {
|
||||
type: "Polygon" as const,
|
||||
coordinates: [
|
||||
[
|
||||
[121.5205, 30.9084],
|
||||
[121.548, 30.928],
|
||||
[121.604, 30.918],
|
||||
[121.626, 30.886],
|
||||
[121.584, 30.862],
|
||||
[121.538, 30.873],
|
||||
[121.506, 30.895],
|
||||
[121.5205, 30.9084]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const annotationPoints = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: mapAnnotations.map((annotation) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: annotation.id,
|
||||
label: annotation.label,
|
||||
kind: annotation.kind
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: annotation.coordinate
|
||||
}
|
||||
}))
|
||||
};
|
||||
@@ -0,0 +1,484 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionStatus,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderState
|
||||
} from "@/features/agent";
|
||||
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
|
||||
import {
|
||||
applyPermissionResponse,
|
||||
applyQuestionResponse,
|
||||
toTodoUpdate,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestion
|
||||
} from "@/features/agent/session-state";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
progress: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
permission_request: Record<string, unknown>;
|
||||
permission_response: Record<string, unknown>;
|
||||
question_request: Record<string, unknown>;
|
||||
question_response: Record<string, unknown>;
|
||||
stream_token: Record<string, unknown>;
|
||||
tool_call: Record<string, unknown>;
|
||||
ui_envelope: Record<string, unknown>;
|
||||
session_title: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
|
||||
|
||||
export type AgentDataPart = Extract<AgentUiMessage["parts"][number], { type: `data-${string}` }>;
|
||||
|
||||
export type PermissionOverride = {
|
||||
status: AgentPermissionStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type QuestionOverride = {
|
||||
status: AgentQuestionRequest["status"];
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
|
||||
type StreamRenderChunkIdRef = MutableRefObject<number>;
|
||||
|
||||
export function appendStreamRenderToken(
|
||||
data: Record<string, unknown>,
|
||||
messages: AgentUiMessage[],
|
||||
setStreamRenderState: StreamRenderStateSetter,
|
||||
chunkIdRef: StreamRenderChunkIdRef
|
||||
) {
|
||||
const text = getDataString(data, "content");
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = getDataString(data, "message_id") ?? getLastAssistantUiMessageId(messages);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkId = chunkIdRef.current;
|
||||
chunkIdRef.current += 1;
|
||||
|
||||
setStreamRenderState((current) => {
|
||||
const previous = current[messageId] ?? { chunks: [], done: false };
|
||||
return {
|
||||
...current,
|
||||
[messageId]: {
|
||||
chunks: [...previous.chunks, { id: chunkId, text }],
|
||||
done: false
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
|
||||
return messages.reduce<AgentStreamRenderState>((next, message) => {
|
||||
if (message.role === "assistant") {
|
||||
next[message.id] = {
|
||||
chunks: [],
|
||||
done: true
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function markLastAssistantStreamDone(
|
||||
messages: AgentUiMessage[],
|
||||
setStreamRenderState: StreamRenderStateSetter
|
||||
) {
|
||||
const messageId = getLastAssistantUiMessageId(messages);
|
||||
setStreamRenderState((current) => {
|
||||
if (!messageId) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(current).map(([id, state]) => [id, { ...state, done: true }])
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[messageId]: {
|
||||
chunks: current[messageId]?.chunks ?? [],
|
||||
done: true
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toAgentChatMessages(
|
||||
messages: AgentUiMessage[],
|
||||
permissionOverrides: Record<string, PermissionOverride>,
|
||||
questionOverrides: Record<string, QuestionOverride>
|
||||
): AgentChatMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let next: AgentChatMessage = {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: collectMessageText(message)
|
||||
};
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (!isAgentDataPart(part)) {
|
||||
continue;
|
||||
}
|
||||
if (part.type === "data-progress") {
|
||||
next = {
|
||||
...next,
|
||||
progress: upsertProgress(next.progress, part.data)
|
||||
};
|
||||
} else if (part.type === "data-todo_update") {
|
||||
const todoUpdate = toTodoUpdate(part.data);
|
||||
if (todoUpdate) {
|
||||
next = {
|
||||
...next,
|
||||
todos: todoUpdate
|
||||
};
|
||||
}
|
||||
} else if (part.type === "data-permission_request") {
|
||||
next = {
|
||||
...next,
|
||||
permissions: upsertPermission(next.permissions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-permission_response") {
|
||||
next = {
|
||||
...next,
|
||||
permissions: applyPermissionResponse(next.permissions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-question_request") {
|
||||
next = {
|
||||
...next,
|
||||
questions: upsertQuestion(next.questions, part.data)
|
||||
};
|
||||
} else if (part.type === "data-question_response") {
|
||||
next = {
|
||||
...next,
|
||||
questions: applyQuestionResponse(next.questions, part.data)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||
});
|
||||
}
|
||||
|
||||
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
if (isAgentUiMessage(message)) {
|
||||
return [message];
|
||||
}
|
||||
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
||||
return legacyMessage ? [legacyMessage] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
|
||||
if (event.type === "state") {
|
||||
return toAgentUiMessages(event.messages);
|
||||
}
|
||||
|
||||
if (event.type === "token") {
|
||||
const token = getDataString(event.data, "content");
|
||||
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "progress" ||
|
||||
event.type === "todo_update" ||
|
||||
event.type === "permission_request" ||
|
||||
event.type === "permission_response" ||
|
||||
event.type === "question_request" ||
|
||||
event.type === "question_response" ||
|
||||
event.type === "ui_envelope" ||
|
||||
event.type === "session_title"
|
||||
) {
|
||||
return updateLastAssistantUiMessage(messages, (message) =>
|
||||
upsertUiDataPart(message, event.type, event.data)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message = getDataString(event.data, "message") ?? "Agent stream failed";
|
||||
return updateLastAssistantUiMessage(messages, (item) =>
|
||||
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function toPermissionStatus(reply: AgentPermissionReply): AgentPermissionStatus {
|
||||
if (reply === "always") {
|
||||
return "approved_always";
|
||||
}
|
||||
if (reply === "once") {
|
||||
return "approved_once";
|
||||
}
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
export function getDataString(data: unknown, key: string) {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (data as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function readBodyString(body: unknown, key: string) {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (body as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getLastAssistantUiMessageId(messages: AgentUiMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "assistant") {
|
||||
return messages[index].id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isAgentUiMessage(value: unknown): value is AgentUiMessage {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const message = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof message.id === "string" &&
|
||||
(message.role === "user" || message.role === "assistant") &&
|
||||
Array.isArray(message.parts)
|
||||
);
|
||||
}
|
||||
|
||||
function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof message.id !== "string" ||
|
||||
(message.role !== "user" && message.role !== "assistant")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: AgentUiMessage["parts"] = [];
|
||||
if (typeof message.content === "string" && message.content) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
appendLegacyDataParts(parts, "progress", message.progress);
|
||||
appendLegacyDataParts(parts, "permission_request", message.permissions);
|
||||
appendLegacyDataParts(parts, "question_request", message.questions);
|
||||
appendLegacyDataParts(parts, "todo_update", message.todos);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts
|
||||
} as AgentUiMessage;
|
||||
}
|
||||
|
||||
function appendLegacyDataParts(
|
||||
parts: AgentUiMessage["parts"],
|
||||
eventType: string,
|
||||
value: unknown
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
values.forEach((item, index) => {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return;
|
||||
}
|
||||
const data = normalizeLegacyDataPart(eventType, item as Record<string, unknown>);
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
`${eventType}-${index}`;
|
||||
parts.push({
|
||||
type: `data-${eventType}`,
|
||||
id,
|
||||
data
|
||||
} as AgentDataPart);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLegacyDataPart(eventType: string, value: Record<string, unknown>) {
|
||||
if (eventType === "progress") {
|
||||
return {
|
||||
...value,
|
||||
started_at: value.started_at ?? value.startedAt,
|
||||
ended_at: value.ended_at ?? value.endedAt,
|
||||
elapsed_ms: value.elapsed_ms ?? value.elapsedMs,
|
||||
duration_ms: value.duration_ms ?? value.durationMs
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === "permission_request" || eventType === "question_request") {
|
||||
return {
|
||||
...value,
|
||||
session_id: value.session_id ?? value.sessionId,
|
||||
request_id: value.request_id ?? value.requestId,
|
||||
created_at: value.created_at ?? value.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === "todo_update") {
|
||||
return {
|
||||
...value,
|
||||
session_id: value.session_id ?? value.sessionId,
|
||||
message_id: value.message_id ?? value.messageId,
|
||||
created_at: value.created_at ?? value.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function updateLastAssistantUiMessage(
|
||||
messages: AgentUiMessage[],
|
||||
updater: (message: AgentUiMessage) => AgentUiMessage
|
||||
) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index].role === "assistant") {
|
||||
const next = [...messages];
|
||||
next[index] = updater(messages[index]);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
return [...messages, updater(createAssistantUiMessage())];
|
||||
}
|
||||
|
||||
function createAssistantUiMessage(): AgentUiMessage {
|
||||
return {
|
||||
id: `assistant-${Date.now().toString(36)}`,
|
||||
role: "assistant",
|
||||
parts: []
|
||||
} as AgentUiMessage;
|
||||
}
|
||||
|
||||
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
|
||||
const textIndex = message.parts.findIndex((part) => part.type === "text");
|
||||
if (textIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [{ type: "text", text }, ...message.parts]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part, index) =>
|
||||
index === textIndex && part.type === "text"
|
||||
? { ...part, text: `${part.text}${text}` }
|
||||
: part
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function upsertUiDataPart(
|
||||
message: AgentUiMessage,
|
||||
eventType: string,
|
||||
data: Record<string, unknown>
|
||||
): AgentUiMessage {
|
||||
const type = `data-${eventType}`;
|
||||
const id =
|
||||
getDataString(data, "id") ??
|
||||
getDataString(data, "request_id") ??
|
||||
getDataString(data, "envelope_id");
|
||||
const existingIndex =
|
||||
id === undefined
|
||||
? -1
|
||||
: message.parts.findIndex((part) => part.type === type && "id" in part && part.id === id);
|
||||
const part = (id ? { type, id, data } : { type, data }) as AgentDataPart;
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return {
|
||||
...message,
|
||||
parts: [...message.parts, part]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((item, index) => (index === existingIndex ? part : item))
|
||||
};
|
||||
}
|
||||
|
||||
function applyPermissionOverrides(
|
||||
message: AgentChatMessage,
|
||||
permissionOverrides: Record<string, PermissionOverride>
|
||||
) {
|
||||
if (!message.permissions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) => {
|
||||
const override = permissionOverrides[permission.requestId];
|
||||
return override
|
||||
? {
|
||||
...permission,
|
||||
status: override.status,
|
||||
error: override.error,
|
||||
repliedAt: override.status === "submitting" || override.status === "error" ? permission.repliedAt : Date.now()
|
||||
}
|
||||
: permission;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function applyQuestionOverrides(
|
||||
message: AgentChatMessage,
|
||||
questionOverrides: Record<string, QuestionOverride>
|
||||
) {
|
||||
if (!message.questions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
questions: message.questions.map((question) => {
|
||||
const override = questionOverrides[question.requestId];
|
||||
return override
|
||||
? {
|
||||
...question,
|
||||
status: override.status,
|
||||
answers: override.answers ?? question.answers,
|
||||
error: override.error,
|
||||
repliedAt: override.status === "submitting" || override.status === "error" ? question.repliedAt : Date.now()
|
||||
}
|
||||
: question;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function collectMessageText(message: AgentUiMessage) {
|
||||
return message.parts
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
|
||||
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { getFeatureId, toDetailFeature } from "../map/feature-adapter";
|
||||
import { SOURCE_LAYERS } from "../map/sources";
|
||||
|
||||
const INTERACTIVE_LAYER_IDS = ["pipes-flow", "junctions"];
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
};
|
||||
|
||||
export function useMapInteractions({
|
||||
mapRef,
|
||||
mapReady,
|
||||
onSelectFeature
|
||||
}: UseMapInteractionsOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
|
||||
function handleMouseMove(event: MapLayerMouseEvent) {
|
||||
activeMap.getCanvas().style.cursor = "pointer";
|
||||
const feature = event.features?.[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getFeatureId(feature);
|
||||
const layerId = feature.sourceLayer === SOURCE_LAYERS.pipes ? "pipes-hover" : "junctions-hover";
|
||||
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
activeMap.getCanvas().style.cursor = "";
|
||||
activeMap.setFilter("pipes-hover", ["==", ["get", "id"], ""]);
|
||||
activeMap.setFilter("junctions-hover", ["==", ["get", "id"], ""]);
|
||||
}
|
||||
|
||||
function handleClick(event: MapLayerMouseEvent) {
|
||||
const feature = event.features?.[0];
|
||||
if (feature) {
|
||||
onSelectFeature(toDetailFeature(feature));
|
||||
}
|
||||
}
|
||||
|
||||
activeMap.on("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
|
||||
activeMap.on("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
|
||||
activeMap.on("click", INTERACTIVE_LAYER_IDS, handleClick);
|
||||
|
||||
return () => {
|
||||
activeMap.off("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
|
||||
activeMap.off("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
|
||||
activeMap.off("click", INTERACTIVE_LAYER_IDS, handleClick);
|
||||
};
|
||||
}, [mapRef, mapReady, onSelectFeature]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
|
||||
type UseSimulationLayerVisibilityOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
visible
|
||||
}: UseSimulationLayerVisibilityOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSimulationLayersVisibility(map, visible);
|
||||
}, [mapRef, mapReady, visible]);
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderState
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
createAgentApiClient,
|
||||
type AgentSessionStreamEvent
|
||||
} from "@/features/agent/api/client";
|
||||
import {
|
||||
agentBootstrapKey,
|
||||
agentSessionsKey,
|
||||
fetchAgentBootstrap,
|
||||
fetchAgentSessions
|
||||
} from "@/features/agent/api/swr";
|
||||
import {
|
||||
isUiEnvelopeAllowed,
|
||||
parseUiEnvelopePayload,
|
||||
type UIEnvelopePayload,
|
||||
type UIRegistry
|
||||
} from "@/features/agent/ui-envelope";
|
||||
import {
|
||||
appendStreamRenderToken,
|
||||
applySessionStreamEvent,
|
||||
createCompletedStreamRenderState,
|
||||
getDataString,
|
||||
markLastAssistantStreamDone,
|
||||
readBodyString,
|
||||
toAgentChatMessages,
|
||||
toAgentUiMessages,
|
||||
toPermissionStatus,
|
||||
type AgentDataPart,
|
||||
type AgentUiMessage,
|
||||
type PermissionOverride,
|
||||
type QuestionOverride
|
||||
} from "./agent-session-message-state";
|
||||
|
||||
const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
|
||||
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
||||
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
const collapseTimerRef = useRef<number | null>(null);
|
||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
const sessionStreamIdRef = useRef<string | null>(null);
|
||||
const streamRenderChunkIdRef = useRef(0);
|
||||
const clientRef = useRef(createAgentApiClient());
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const registryRef = useRef<UIRegistry | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
||||
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
|
||||
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
|
||||
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId;
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
registryRef.current = registry;
|
||||
}, [registry]);
|
||||
|
||||
const applySessionId = useCallback((nextSessionId: string | undefined) => {
|
||||
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
sessionIdRef.current = nextSessionId;
|
||||
setSessionId(nextSessionId);
|
||||
}, []);
|
||||
|
||||
const clearSessionId = useCallback(() => {
|
||||
sessionIdRef.current = null;
|
||||
setSessionId(null);
|
||||
}, []);
|
||||
|
||||
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
||||
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
|
||||
}, []);
|
||||
|
||||
const handleDataPart = useCallback(
|
||||
(part: AgentDataPart) => {
|
||||
const eventSessionId = getDataString(part.data, "session_id");
|
||||
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
applySessionId(eventSessionId);
|
||||
|
||||
if (part.type === "data-stream_token") {
|
||||
appendStreamRenderToken(
|
||||
part.data,
|
||||
chatRef.current.messages,
|
||||
setStreamRenderState,
|
||||
streamRenderChunkIdRef
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === "data-progress") {
|
||||
const title = getDataString(part.data, "title");
|
||||
if (title) {
|
||||
setStatusLabel(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === "data-session_title") {
|
||||
const title = getDataString(part.data, "title");
|
||||
if (title) {
|
||||
setSessionTitle(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type !== "data-ui_envelope") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseUiEnvelopePayload(part.data);
|
||||
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
|
||||
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "Agent 展示已降级",
|
||||
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "Agent UIEnvelope 处理失败",
|
||||
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||
});
|
||||
});
|
||||
},
|
||||
[applySessionId, onUiEnvelope]
|
||||
);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
api: "/api/v1/agent/chat/stream",
|
||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
id,
|
||||
messages,
|
||||
trigger,
|
||||
messageId,
|
||||
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
|
||||
approval_mode: readBodyString(body, "approval_mode") ?? "request"
|
||||
}
|
||||
};
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const chat = useChat<AgentUiMessage>({
|
||||
transport,
|
||||
onData: (part) => handleDataPart(part as AgentDataPart),
|
||||
onError: (error) => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setRuntimeAvailability("failed");
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
tone: "error",
|
||||
title: "Agent 请求失败",
|
||||
message: error.message || "请确认后端服务已启动。"
|
||||
});
|
||||
},
|
||||
onFinish: () => {
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 分析完成");
|
||||
}
|
||||
});
|
||||
const chatRef = useRef(chat);
|
||||
|
||||
useEffect(() => {
|
||||
chatRef.current = chat;
|
||||
}, [chat]);
|
||||
|
||||
const {
|
||||
data: backendConnection,
|
||||
error: backendConnectionError
|
||||
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
|
||||
shouldRetryOnError: false
|
||||
});
|
||||
const {
|
||||
data: sessionHistory = [],
|
||||
isLoading: sessionHistoryLoading,
|
||||
isValidating: sessionHistoryValidating,
|
||||
mutate: mutateSessionHistory
|
||||
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false
|
||||
});
|
||||
|
||||
const clearCollapseTimer = useCallback(() => {
|
||||
if (collapseTimerRef.current !== null) {
|
||||
window.clearTimeout(collapseTimerRef.current);
|
||||
collapseTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMobileCollapseTimer = useCallback(() => {
|
||||
if (mobileCollapseTimerRef.current !== null) {
|
||||
window.clearTimeout(mobileCollapseTimerRef.current);
|
||||
mobileCollapseTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSessionStreamSubscription = useCallback(() => {
|
||||
sessionStreamAbortRef.current?.abort();
|
||||
sessionStreamAbortRef.current = null;
|
||||
sessionStreamIdRef.current = null;
|
||||
setResumedStreaming(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCollapseTimer();
|
||||
clearMobileCollapseTimer();
|
||||
stopSessionStreamSubscription();
|
||||
chatRef.current.stop();
|
||||
};
|
||||
}, [clearCollapseTimer, clearMobileCollapseTimer, stopSessionStreamSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
registryRef.current = backendConnection.registry;
|
||||
setRegistry(backendConnection.registry);
|
||||
setModelOptions(backendConnection.models);
|
||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||
if (chatRef.current.status === "ready") {
|
||||
setRuntimeAvailability("connected");
|
||||
setStatusLabel("Agent 后端已连接");
|
||||
}
|
||||
}, [backendConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendConnectionError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeAvailability("unavailable");
|
||||
setStatusLabel("Agent 后端不可用");
|
||||
}, [backendConnectionError]);
|
||||
|
||||
const collapsePanel = useCallback(() => {
|
||||
clearCollapseTimer();
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
setPanelOpen(false);
|
||||
setPanelCollapsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelCollapsing(true);
|
||||
collapseTimerRef.current = window.setTimeout(() => {
|
||||
setPanelOpen(false);
|
||||
setPanelCollapsing(false);
|
||||
collapseTimerRef.current = null;
|
||||
}, AGENT_PANEL_COLLAPSE_MS);
|
||||
}, [clearCollapseTimer]);
|
||||
|
||||
const expandPanel = useCallback(() => {
|
||||
clearCollapseTimer();
|
||||
setPanelCollapsing(false);
|
||||
setPanelOpen(true);
|
||||
}, [clearCollapseTimer]);
|
||||
|
||||
const openMobilePanel = useCallback(() => {
|
||||
clearMobileCollapseTimer();
|
||||
setMobilePanelCollapsing(false);
|
||||
setMobileOpen(true);
|
||||
}, [clearMobileCollapseTimer]);
|
||||
|
||||
const closeMobilePanel = useCallback(() => {
|
||||
clearMobileCollapseTimer();
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
setMobileOpen(false);
|
||||
setMobilePanelCollapsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMobilePanelCollapsing(true);
|
||||
mobileCollapseTimerRef.current = window.setTimeout(() => {
|
||||
setMobileOpen(false);
|
||||
setMobilePanelCollapsing(false);
|
||||
mobileCollapseTimerRef.current = null;
|
||||
}, AGENT_PANEL_COLLAPSE_MS);
|
||||
}, [clearMobileCollapseTimer]);
|
||||
|
||||
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
|
||||
const messages = useMemo(
|
||||
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
|
||||
[chat.messages, permissionOverrides, questionOverrides]
|
||||
);
|
||||
const personaState = useMemo(
|
||||
() => deriveAgentPersonaState(messages, {
|
||||
chatStatus: chat.status,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
}),
|
||||
[chat.status, messages, runtimeAvailability, statusLabel]
|
||||
);
|
||||
|
||||
const refreshSessionHistory = useCallback(async () => {
|
||||
try {
|
||||
await mutateSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录加载失败",
|
||||
message: error instanceof Error ? error.message : "无法获取 Agent 会话历史。"
|
||||
});
|
||||
}
|
||||
}, [mutateSessionHistory]);
|
||||
|
||||
const handleSessionStreamEvent = useCallback(
|
||||
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
|
||||
if (event.type === "state") {
|
||||
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
|
||||
return;
|
||||
}
|
||||
const nextMessages = toAgentUiMessages(event.messages);
|
||||
chatRef.current.setMessages(nextMessages);
|
||||
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||
setResumedStreaming(event.isStreaming);
|
||||
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSessionId = event.sessionId;
|
||||
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "session_title") {
|
||||
const title = getDataString(event.data, "title");
|
||||
if (title) {
|
||||
setSessionTitle(title);
|
||||
}
|
||||
} else if (event.type === "token") {
|
||||
appendStreamRenderToken(
|
||||
event.data,
|
||||
chatRef.current.messages,
|
||||
setStreamRenderState,
|
||||
streamRenderChunkIdRef
|
||||
);
|
||||
} else if (event.type === "ui_envelope") {
|
||||
const payload = parseUiEnvelopePayload(event.data);
|
||||
const activeSessionId = eventSessionId ?? sessionIdRef.current;
|
||||
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "Agent 展示已降级",
|
||||
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "Agent UIEnvelope 处理失败",
|
||||
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||
});
|
||||
});
|
||||
} else if (event.type === "progress") {
|
||||
const title = getDataString(event.data, "title");
|
||||
if (title) {
|
||||
setStatusLabel(title);
|
||||
}
|
||||
} else if (event.type === "done") {
|
||||
setResumedStreaming(false);
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 分析完成");
|
||||
void refreshSessionHistory();
|
||||
} else if (event.type === "error") {
|
||||
setResumedStreaming(false);
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
setStatusLabel("Agent 后端请求失败");
|
||||
void refreshSessionHistory();
|
||||
}
|
||||
|
||||
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
|
||||
},
|
||||
[onUiEnvelope, refreshSessionHistory]
|
||||
);
|
||||
|
||||
const startSessionStreamSubscription = useCallback(
|
||||
(nextSessionId: string) => {
|
||||
stopSessionStreamSubscription();
|
||||
|
||||
const controller = new AbortController();
|
||||
sessionStreamAbortRef.current = controller;
|
||||
sessionStreamIdRef.current = nextSessionId;
|
||||
setResumedStreaming(true);
|
||||
|
||||
void clientRef.current
|
||||
.streamSession(nextSessionId, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => handleSessionStreamEvent(event, nextSessionId)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRuntimeAvailability("failed");
|
||||
setStatusLabel("Agent 会话流恢复失败");
|
||||
showMapNotice({
|
||||
id: "agent-stream-status",
|
||||
tone: "error",
|
||||
title: "Agent 会话流恢复失败",
|
||||
message: error instanceof Error ? error.message : "无法恢复这个运行中的会话。"
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (sessionStreamAbortRef.current === controller) {
|
||||
sessionStreamAbortRef.current = null;
|
||||
sessionStreamIdRef.current = null;
|
||||
setResumedStreaming(false);
|
||||
void refreshSessionHistory();
|
||||
}
|
||||
});
|
||||
},
|
||||
[handleSessionStreamEvent, refreshSessionHistory, stopSessionStreamSubscription]
|
||||
);
|
||||
|
||||
const loadHistorySession = useCallback(
|
||||
async (nextSessionId: string) => {
|
||||
if (streaming) {
|
||||
chat.stop();
|
||||
stopSessionStreamSubscription();
|
||||
}
|
||||
|
||||
try {
|
||||
const loadedSession = await clientRef.current.loadSession(nextSessionId);
|
||||
if (!loadedSession) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "warning",
|
||||
title: "Agent 历史记录不可用",
|
||||
message: "这个历史会话不存在或已被删除。"
|
||||
});
|
||||
await refreshSessionHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
applySessionId(loadedSession.sessionId);
|
||||
setSessionTitle(loadedSession.title);
|
||||
setStatusLabel("Agent 历史会话已打开");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
const nextMessages = toAgentUiMessages(loadedSession.messages);
|
||||
chat.setMessages(nextMessages);
|
||||
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
||||
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
|
||||
startSessionStreamSubscription(loadedSession.sessionId);
|
||||
}
|
||||
await refreshSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录打开失败",
|
||||
message: error instanceof Error ? error.message : "无法打开这个历史会话。"
|
||||
});
|
||||
}
|
||||
},
|
||||
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
|
||||
);
|
||||
|
||||
const renameHistorySession = useCallback(
|
||||
async (nextSessionId: string, title: string) => {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
showMapNotice({ tone: "warning", message: "对话主题不能为空。" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
|
||||
isTitleManuallyEdited: true
|
||||
});
|
||||
await mutateSessionHistory(
|
||||
(current = []) =>
|
||||
current.map((session) =>
|
||||
session.id === nextSessionId
|
||||
? {
|
||||
...session,
|
||||
title: normalizedTitle,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
: session
|
||||
),
|
||||
{ revalidate: false }
|
||||
);
|
||||
if (sessionIdRef.current === nextSessionId) {
|
||||
setSessionTitle(normalizedTitle);
|
||||
}
|
||||
await refreshSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 对话重命名失败",
|
||||
message: error instanceof Error ? error.message : "无法更新这个对话主题。"
|
||||
});
|
||||
}
|
||||
},
|
||||
[mutateSessionHistory, refreshSessionHistory]
|
||||
);
|
||||
|
||||
const resetDraftSession = useCallback(
|
||||
(nextStatusLabel = "Agent 新对话已准备") => {
|
||||
clearSessionId();
|
||||
setSessionTitle("新对话");
|
||||
setStatusLabel(nextStatusLabel);
|
||||
setRuntimeAvailability("connected");
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
setStreamRenderState({});
|
||||
chat.setMessages([]);
|
||||
},
|
||||
[chat, clearSessionId]
|
||||
);
|
||||
|
||||
const deleteHistorySession = useCallback(
|
||||
async (nextSessionId: string) => {
|
||||
if (streaming) {
|
||||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientRef.current.deleteSession(nextSessionId);
|
||||
await mutateSessionHistory(
|
||||
(current = []) => current.filter((session) => session.id !== nextSessionId),
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
if (sessionIdRef.current === nextSessionId) {
|
||||
resetDraftSession();
|
||||
}
|
||||
|
||||
await refreshSessionHistory();
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "agent-history-status",
|
||||
tone: "error",
|
||||
title: "Agent 历史记录删除失败",
|
||||
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
|
||||
});
|
||||
}
|
||||
},
|
||||
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
|
||||
);
|
||||
|
||||
const startNewSession = useCallback(async () => {
|
||||
if (chat.status === "submitted" || chat.status === "streaming") {
|
||||
chat.stop();
|
||||
}
|
||||
stopSessionStreamSubscription();
|
||||
|
||||
resetDraftSession();
|
||||
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
|
||||
|
||||
const submitPrompt = useCallback(
|
||||
async (prompt: string) => {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (!normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusLabel("Agent 正在分析");
|
||||
|
||||
await chat.sendMessage(
|
||||
{ text: normalizedPrompt },
|
||||
{
|
||||
body: {
|
||||
session_id: sessionIdRef.current ?? undefined,
|
||||
model: selectedModel || undefined,
|
||||
approval_mode: "request"
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[chat, selectedModel, streaming]
|
||||
);
|
||||
|
||||
const stopPrompt = useCallback(() => {
|
||||
chat.stop();
|
||||
stopSessionStreamSubscription();
|
||||
const activeSessionId = sessionIdRef.current;
|
||||
if (activeSessionId) {
|
||||
void clientRef.current.abort(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
||||
}, [chat, stopSessionStreamSubscription]);
|
||||
|
||||
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.replyPermission(permission.requestId, {
|
||||
sessionId: permission.sessionId,
|
||||
reply
|
||||
});
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: {
|
||||
status: toPermissionStatus(reply)
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "权限请求提交失败。";
|
||||
setPermissionOverrides((current) => ({
|
||||
...current,
|
||||
[permission.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-permission-status",
|
||||
tone: "error",
|
||||
title: "Agent 权限提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.replyQuestion(question.requestId, {
|
||||
sessionId: question.sessionId,
|
||||
answers
|
||||
});
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "answered",
|
||||
answers
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "问题回复提交失败。";
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-question-status",
|
||||
tone: "error",
|
||||
title: "Agent 问题提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: { status: "submitting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await clientRef.current.rejectQuestion(question.requestId, {
|
||||
sessionId: question.sessionId
|
||||
});
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "rejected"
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "问题跳过提交失败。";
|
||||
setQuestionOverrides((current) => ({
|
||||
...current,
|
||||
[question.requestId]: {
|
||||
status: "error",
|
||||
error: message
|
||||
}
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "agent-question-status",
|
||||
tone: "error",
|
||||
title: "Agent 问题提交失败",
|
||||
message
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
collapsePanel,
|
||||
expandPanel,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
messages,
|
||||
modelOptions,
|
||||
openMobilePanel,
|
||||
closeMobilePanel,
|
||||
panelCollapsing,
|
||||
panelOpen,
|
||||
personaState,
|
||||
refreshSessionHistory,
|
||||
startNewSession,
|
||||
loadHistorySession,
|
||||
renameHistorySession,
|
||||
deleteHistorySession,
|
||||
sessionHistory,
|
||||
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
statusLabel,
|
||||
streamRenderState,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
stopPrompt,
|
||||
streaming,
|
||||
rejectQuestion,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
resolveRenderRef,
|
||||
submitPrompt
|
||||
};
|
||||
}
|
||||
|
||||
function deriveAgentPersonaState(
|
||||
messages: AgentChatMessage[],
|
||||
{
|
||||
chatStatus,
|
||||
runtimeAvailability,
|
||||
statusLabel
|
||||
}: {
|
||||
chatStatus: string;
|
||||
runtimeAvailability: AgentRuntimeAvailability;
|
||||
statusLabel: string;
|
||||
}
|
||||
): PersonaState {
|
||||
if (
|
||||
runtimeAvailability === "unavailable" ||
|
||||
runtimeAvailability === "failed" ||
|
||||
/失败|不可用|错误|降级/.test(statusLabel)
|
||||
) {
|
||||
return "asleep";
|
||||
}
|
||||
|
||||
if (hasPendingOperatorInput(messages)) {
|
||||
return "listening";
|
||||
}
|
||||
|
||||
if (
|
||||
chatStatus === "submitted" ||
|
||||
chatStatus === "streaming" ||
|
||||
hasRunningSessionWork(messages) ||
|
||||
runtimeAvailability === "connecting"
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
||||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
||||
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
||||
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
||||
|
||||
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
DRAWING_INTERACTIVE_LAYER_IDS,
|
||||
DRAWING_SOURCE_IDS,
|
||||
createCircleFeature,
|
||||
createLineFeature,
|
||||
createPointFeature,
|
||||
createPolygonFeature,
|
||||
emptyDrawingFeatureCollection,
|
||||
ensureDrawingLayers,
|
||||
setSelectedDrawingFeatureId,
|
||||
setDrawingSourceData,
|
||||
type DrawingFeatureCollection
|
||||
} from "../map/drawing-layers";
|
||||
import {
|
||||
createDrawingId,
|
||||
createDrawingLabel,
|
||||
createFeatureFromDraft,
|
||||
formatDrawingTime,
|
||||
getDraftPointCount,
|
||||
getDrawingKindLabel,
|
||||
getVisibleFeatureCollection,
|
||||
type DrawingDraftState,
|
||||
type WorkbenchDrawMode
|
||||
} from "../map/drawing-model";
|
||||
import { getDistanceMeters, isSameCoordinate } from "../map/geo";
|
||||
|
||||
export type { WorkbenchDrawMode };
|
||||
|
||||
export type WorkbenchDrawingAnnotation = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
kind: WorkbenchDrawMode;
|
||||
hidden: boolean;
|
||||
selected: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
type UseWorkbenchDrawingOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
activeMode: WorkbenchDrawMode | null;
|
||||
onModeChange: (mode: WorkbenchDrawMode | null) => void;
|
||||
};
|
||||
|
||||
export function useWorkbenchDrawing({
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode,
|
||||
onModeChange
|
||||
}: UseWorkbenchDrawingOptions) {
|
||||
const [featureCollection, setFeatureCollection] = useState<DrawingFeatureCollection>(emptyDrawingFeatureCollection);
|
||||
const [hiddenFeatureIds, setHiddenFeatureIds] = useState<Set<string>>(() => new Set());
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
||||
const [, setHistoryVersion] = useState(0);
|
||||
const [draftPointCount, setDraftPointCount] = useState(0);
|
||||
const featureCollectionRef = useRef(featureCollection);
|
||||
const hiddenFeatureIdsRef = useRef(hiddenFeatureIds);
|
||||
const undoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||
const redoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
||||
const draftRef = useRef<DrawingDraftState | null>(null);
|
||||
const featureCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
featureCollectionRef.current = featureCollection;
|
||||
}, [featureCollection]);
|
||||
|
||||
useEffect(() => {
|
||||
hiddenFeatureIdsRef.current = hiddenFeatureIds;
|
||||
}, [hiddenFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDrawingLayers(map);
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollectionRef.current, hiddenFeatureIdsRef.current));
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
draftRef.current = null;
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
}
|
||||
}, [mapRef]);
|
||||
|
||||
const updateFeatures = useCallback(
|
||||
(nextCollection: DrawingFeatureCollection) => {
|
||||
featureCollectionRef.current = nextCollection;
|
||||
setFeatureCollection(nextCollection);
|
||||
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(nextCollection, hiddenFeatureIdsRef.current));
|
||||
}
|
||||
},
|
||||
[mapRef]
|
||||
);
|
||||
|
||||
const commitFeatures = useCallback(
|
||||
(nextCollection: DrawingFeatureCollection) => {
|
||||
undoStackRef.current.push(featureCollectionRef.current);
|
||||
redoStackRef.current = [];
|
||||
updateFeatures(nextCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
},
|
||||
[updateFeatures]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollection, hiddenFeatureIds));
|
||||
}, [featureCollection, hiddenFeatureIds, mapReady, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDrawingFeatureId(map, selectedFeatureId);
|
||||
}, [mapReady, mapRef, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || activeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
|
||||
function handleDrawingClick(event: MapMouseEvent) {
|
||||
const feature = activeMap.queryRenderedFeatures(event.point, {
|
||||
layers: DRAWING_INTERACTIVE_LAYER_IDS
|
||||
})[0];
|
||||
const id = feature?.properties?.id;
|
||||
if (typeof id === "string" && id) {
|
||||
setSelectedFeatureId((current) => (current === id ? null : id));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
activeMap.getCanvas().style.cursor = "pointer";
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
activeMap.getCanvas().style.cursor = "";
|
||||
}
|
||||
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.on("click", layerId, handleDrawingClick);
|
||||
activeMap.on("mouseenter", layerId, handleMouseEnter);
|
||||
activeMap.on("mouseleave", layerId, handleMouseLeave);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.off("click", layerId, handleDrawingClick);
|
||||
activeMap.off("mouseenter", layerId, handleMouseEnter);
|
||||
activeMap.off("mouseleave", layerId, handleMouseLeave);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [activeMode, mapReady, mapRef]);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(draft: DrawingDraftState | null) => {
|
||||
draftRef.current = draft;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft || getDraftPointCount(draft) === 0) {
|
||||
setDraftPointCount(0);
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftPointCount(getDraftPointCount(draft));
|
||||
|
||||
const draftFeatures: DrawingFeatureCollection["features"] =
|
||||
draft.mode === "circle"
|
||||
? [createPointFeature("draft-circle-center", draft.center, "圆心")]
|
||||
: draft.coordinates.map((coordinates, index) =>
|
||||
createPointFeature(`draft-vertex-${index}`, coordinates, `顶点 ${index + 1}`)
|
||||
);
|
||||
|
||||
if (draft.mode !== "circle" && draft.coordinates.length >= 2) {
|
||||
draftFeatures.push(createLineFeature("draft-line", draft.coordinates, "绘制草稿"));
|
||||
}
|
||||
|
||||
if (draft.mode === "polygon" && draft.coordinates.length >= 3) {
|
||||
draftFeatures.push(createPolygonFeature("draft-polygon", draft.coordinates, "范围草稿"));
|
||||
}
|
||||
|
||||
if (draft.mode === "circle" && draft.edge) {
|
||||
draftFeatures.push(createPointFeature("draft-circle-edge", draft.edge, "半径"));
|
||||
draftFeatures.push(createLineFeature("draft-circle-radius", [draft.center, draft.edge], "圆形半径"));
|
||||
draftFeatures.push(createCircleFeature("draft-circle", draft.center, getDistanceMeters(draft.center, draft.edge), "圆形草稿"));
|
||||
}
|
||||
|
||||
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, {
|
||||
type: "FeatureCollection",
|
||||
features: draftFeatures
|
||||
});
|
||||
},
|
||||
[mapRef]
|
||||
);
|
||||
|
||||
const finishDraft = useCallback(() => {
|
||||
const draft = draftRef.current;
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minimumPoints = draft.mode === "line" ? 2 : draft.mode === "polygon" ? 3 : 2;
|
||||
const draftPointCount = getDraftPointCount(draft);
|
||||
if (draftPointCount < minimumPoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = createDrawingId(draft.mode, featureCounterRef.current + 1);
|
||||
featureCounterRef.current += 1;
|
||||
const label = createDrawingLabel(draft.mode, featureCounterRef.current);
|
||||
const feature = createFeatureFromDraft(id, draft, label);
|
||||
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: [...featureCollectionRef.current.features, feature]
|
||||
});
|
||||
updateDraft(null);
|
||||
onModeChange(null);
|
||||
}, [commitFeatures, onModeChange, updateDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !activeMode) {
|
||||
clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const drawingMode = activeMode;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
|
||||
if (drawingMode !== "point") {
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function handleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
const coordinates: [number, number] = [event.lngLat.lng, event.lngLat.lat];
|
||||
|
||||
if (drawingMode === "point") {
|
||||
const nextNumber = featureCounterRef.current + 1;
|
||||
const id = createDrawingId(drawingMode, nextNumber);
|
||||
featureCounterRef.current = nextNumber;
|
||||
const feature = createPointFeature(id, coordinates, createDrawingLabel("point", nextNumber));
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: [...featureCollectionRef.current.features, feature]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawingMode === "circle") {
|
||||
const currentDraft = draftRef.current?.mode === "circle" ? draftRef.current : null;
|
||||
if (!currentDraft) {
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: coordinates
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: currentDraft.center,
|
||||
edge: coordinates
|
||||
});
|
||||
finishDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const draftMode: DrawingDraftState["mode"] = drawingMode === "polygon" ? "polygon" : "line";
|
||||
const currentDraft = draftRef.current?.mode === draftMode ? draftRef.current : { mode: draftMode, coordinates: [] };
|
||||
if (isSameCoordinate(currentDraft.coordinates[currentDraft.coordinates.length - 1], coordinates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: draftMode,
|
||||
coordinates: [...currentDraft.coordinates, coordinates]
|
||||
});
|
||||
}
|
||||
|
||||
function handleDoubleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
finishDraft();
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MapMouseEvent) {
|
||||
if (drawingMode !== "circle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDraft = draftRef.current;
|
||||
if (currentDraft?.mode !== "circle") {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraft({
|
||||
mode: "circle",
|
||||
center: currentDraft.center,
|
||||
edge: [event.lngLat.lng, event.lngLat.lat]
|
||||
});
|
||||
}
|
||||
|
||||
map.on("click", handleClick);
|
||||
map.on("dblclick", handleDoubleClick);
|
||||
map.on("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
map.off("dblclick", handleDoubleClick);
|
||||
map.off("mousemove", handleMouseMove);
|
||||
canvas.style.cursor = previousCursor;
|
||||
|
||||
if (drawingMode !== "point") {
|
||||
map.doubleClickZoom.enable();
|
||||
}
|
||||
};
|
||||
}, [activeMode, clearDraft, commitFeatures, finishDraft, mapReady, mapRef, updateDraft]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const draft = draftRef.current;
|
||||
if (draft && getDraftPointCount(draft) > 0) {
|
||||
if (draft.mode === "circle") {
|
||||
updateDraft(draft.edge ? { mode: "circle", center: draft.center } : null);
|
||||
} else {
|
||||
updateDraft({
|
||||
mode: draft.mode,
|
||||
coordinates: draft.coordinates.slice(0, -1)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCollection = undoStackRef.current.pop();
|
||||
if (!previousCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
redoStackRef.current.push(featureCollectionRef.current);
|
||||
updateFeatures(previousCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
}, [updateDraft, updateFeatures]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const nextCollection = redoStackRef.current.pop();
|
||||
if (!nextCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
undoStackRef.current.push(featureCollectionRef.current);
|
||||
updateFeatures(nextCollection);
|
||||
setHistoryVersion((current) => current + 1);
|
||||
}, [updateFeatures]);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (!selectedFeatureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFeatures = featureCollectionRef.current.features.filter((feature) => feature.properties.id !== selectedFeatureId);
|
||||
if (nextFeatures.length === featureCollectionRef.current.features.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFeatureId(null);
|
||||
setHiddenFeatureIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(selectedFeatureId);
|
||||
return next;
|
||||
});
|
||||
commitFeatures({
|
||||
type: "FeatureCollection",
|
||||
features: nextFeatures
|
||||
});
|
||||
}, [commitFeatures, selectedFeatureId]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearDraft();
|
||||
setHiddenFeatureIds(new Set());
|
||||
setSelectedFeatureId(null);
|
||||
commitFeatures(emptyDrawingFeatureCollection);
|
||||
onModeChange(null);
|
||||
}, [clearDraft, commitFeatures, onModeChange]);
|
||||
|
||||
const toggleVisibility = useCallback((featureId: string) => {
|
||||
setHiddenFeatureIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(featureId)) {
|
||||
next.delete(featureId);
|
||||
} else {
|
||||
next.add(featureId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exportGeoJSON = useCallback(() => {
|
||||
const data = JSON.stringify(featureCollectionRef.current, null, 2);
|
||||
const blob = new Blob([data], { type: "application/geo+json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "workbench-annotations.geojson";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const annotations = useMemo<WorkbenchDrawingAnnotation[]>(
|
||||
() =>
|
||||
featureCollection.features.map((feature) => ({
|
||||
id: feature.properties.id,
|
||||
label: feature.properties.label,
|
||||
description: `${getDrawingKindLabel(feature.properties.kind)} · ${formatDrawingTime(feature.properties.createdAt)}`,
|
||||
kind: feature.properties.kind,
|
||||
hidden: hiddenFeatureIds.has(feature.properties.id),
|
||||
selected: selectedFeatureId === feature.properties.id,
|
||||
onToggleVisibility: () => toggleVisibility(feature.properties.id),
|
||||
onSelect: () => setSelectedFeatureId(feature.properties.id)
|
||||
})),
|
||||
[featureCollection, hiddenFeatureIds, selectedFeatureId, toggleVisibility]
|
||||
);
|
||||
|
||||
return {
|
||||
annotations,
|
||||
canUndo: Boolean(draftPointCount || undoStackRef.current.length),
|
||||
canRedo: Boolean(redoStackRef.current.length),
|
||||
canDeleteSelected: Boolean(selectedFeatureId),
|
||||
hasFeatures: featureCollection.features.length > 0,
|
||||
undo,
|
||||
redo,
|
||||
deleteSelected,
|
||||
clear,
|
||||
exportGeoJSON
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
SIMULATION_SOURCE_IDS,
|
||||
simulationAnnotationLayers,
|
||||
simulationSources
|
||||
} from "../map/annotation-layers";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding
|
||||
} from "../map/camera";
|
||||
import { waterNetworkLayers } from "../map/layers";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
import {
|
||||
createBaseStyle,
|
||||
createWaterNetworkSources,
|
||||
WATER_NETWORK_GLOBAL_VIEW
|
||||
} from "../map/sources";
|
||||
import { useMapInteractions } from "./use-map-interactions";
|
||||
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__waterNetworkMap?: MapLibreMap;
|
||||
}
|
||||
}
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
leftPanelOpen: boolean;
|
||||
rightPanelOpen: boolean;
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
};
|
||||
|
||||
type UseWorkbenchMapResult = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
mapError: string | null;
|
||||
sourceStatuses: WorkbenchSourceStatus[];
|
||||
fitNetworkBounds: () => void;
|
||||
};
|
||||
|
||||
export type WorkbenchSourceStatusValue = "loading" | "online" | "degraded" | "offline";
|
||||
|
||||
export type WorkbenchSourceStatus = {
|
||||
id: string;
|
||||
sourceName: string;
|
||||
status: WorkbenchSourceStatusValue;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
"mapbox-base": "Mapbox 底图",
|
||||
"geoserver-mvt": "GeoServer MVT",
|
||||
"annotation-source": "业务标注源"
|
||||
};
|
||||
|
||||
const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "mapbox-base",
|
||||
"mapbox-satellite": "mapbox-base",
|
||||
pipes: "geoserver-mvt",
|
||||
junctions: "geoserver-mvt",
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const appliedLayoutPaddingRef = useRef<{ leftPanelOpen: boolean; rightPanelOpen: boolean } | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
|
||||
}, [leftPanelOpen, rightPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
}, [impactVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: createBaseStyle(mapboxToken),
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
preserveDrawingBuffer: true,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
window.__waterNetworkMap = map;
|
||||
|
||||
map.on("load", () => {
|
||||
map.resize();
|
||||
const sources = createWaterNetworkSources();
|
||||
map.addSource("pipes", sources.pipes);
|
||||
map.addSource("junctions", sources.junctions);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
waterNetworkLayers.forEach((layer) => map.addLayer(layer));
|
||||
simulationAnnotationLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
|
||||
setMapReady(true);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
const resizeMap = () => map.resize();
|
||||
window.addEventListener("resize", resizeMap);
|
||||
window.setTimeout(resizeMap, 0);
|
||||
window.setTimeout(resizeMap, 300);
|
||||
window.setTimeout(resizeMap, 1000);
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||
});
|
||||
|
||||
map.on("sourcedataabort", (event) => {
|
||||
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
|
||||
});
|
||||
|
||||
map.on("error", (event) => {
|
||||
const message = event.error?.message ?? "地图渲染异常";
|
||||
if (!message.includes("AbortError")) {
|
||||
const sourceGroupId = getSourceGroupFromErrorEvent(event);
|
||||
if (sourceGroupId) {
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
|
||||
} else {
|
||||
setMapError(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeMap);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
useMapInteractions({
|
||||
mapRef,
|
||||
mapReady,
|
||||
onSelectFeature
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLayout = appliedLayoutPaddingRef.current;
|
||||
const currentLayout = { leftPanelOpen, rightPanelOpen };
|
||||
appliedLayoutPaddingRef.current = currentLayout;
|
||||
|
||||
if (
|
||||
!previousLayout ||
|
||||
(previousLayout.leftPanelOpen === currentLayout.leftPanelOpen &&
|
||||
previousLayout.rightPanelOpen === currentLayout.rightPanelOpen)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration: 350
|
||||
});
|
||||
}, [leftPanelOpen, rightPanelOpen, mapReady]);
|
||||
|
||||
useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
visible: impactVisible
|
||||
});
|
||||
|
||||
function fitToNetworkBounds() {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mapRef,
|
||||
mapReady,
|
||||
mapError,
|
||||
sourceStatuses: Object.values(sourceStatuses),
|
||||
fitNetworkBounds: fitToNetworkBounds
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatusFromSourceEvent(
|
||||
event: MapSourceDataEvent,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
|
||||
) {
|
||||
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
if (!sourceGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "online" && !event.isSourceLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
|
||||
}
|
||||
|
||||
function updateSourceStatus(
|
||||
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
|
||||
sourceGroupId: string,
|
||||
status: WorkbenchSourceStatusValue,
|
||||
message: string
|
||||
) {
|
||||
setSourceStatuses((current) => {
|
||||
const previous = current[sourceGroupId];
|
||||
if (previous?.status === status && previous.message === message) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[sourceGroupId]: {
|
||||
id: sourceGroupId,
|
||||
sourceName: SOURCE_STATUS_LABELS[sourceGroupId] ?? sourceGroupId,
|
||||
status,
|
||||
message
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
||||
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
||||
return SOURCE_GROUP_BY_ID[event.sourceId];
|
||||
}
|
||||
|
||||
const message = event.error?.message ?? "";
|
||||
if (message.includes("geoserver.waternetwork.cn")) {
|
||||
return "geoserver-mvt";
|
||||
}
|
||||
|
||||
if (message.includes("api.mapbox.com") || message.includes("mapbox")) {
|
||||
return "mapbox-base";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return status === "online" ? "管线和节点矢量瓦片已加载。" : "管线或节点瓦片请求中断,当前视图可能不完整。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
}
|
||||
|
||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
}
|
||||
|
||||
function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
|
||||
if (sourceGroupId === "mapbox-base") {
|
||||
return `底图瓦片请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
if (sourceGroupId === "geoserver-mvt") {
|
||||
return `GeoServer 矢量瓦片请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
if (sourceGroupId === "annotation-source") {
|
||||
return `业务标注源请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
createMeasurementFeatureCollection,
|
||||
emptyMeasurementFeatureCollection,
|
||||
ensureMeasurementLayers,
|
||||
setSelectedMeasurementPipeIds,
|
||||
setMeasurementSourceData
|
||||
} from "../map/measurement-layers";
|
||||
import { getFeatureId } from "../map/feature-adapter";
|
||||
import {
|
||||
getLastSegmentDistanceMeters,
|
||||
getLineDistanceMeters,
|
||||
getPolygonAreaSquareMeters,
|
||||
isSameCoordinate,
|
||||
type LngLatTuple
|
||||
} from "../map/geo";
|
||||
|
||||
export type WorkbenchMeasureMode = "distance" | "area" | "segment";
|
||||
export type WorkbenchMeasureUnit = "m" | "km";
|
||||
export type WorkbenchMeasurementResult = {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
metrics: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
type UseWorkbenchMeasurementOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
mode: WorkbenchMeasureMode;
|
||||
unit: WorkbenchMeasureUnit;
|
||||
};
|
||||
|
||||
const SNAP_RADIUS_PIXELS = 14;
|
||||
const SNAP_LAYER_IDS = ["pipes-flow"];
|
||||
|
||||
type SelectedPipe = {
|
||||
id: string;
|
||||
lengthMeters?: number;
|
||||
};
|
||||
|
||||
export function useWorkbenchMeasurement({
|
||||
mapRef,
|
||||
mapReady,
|
||||
mode,
|
||||
unit
|
||||
}: UseWorkbenchMeasurementOptions) {
|
||||
const [coordinates, setCoordinates] = useState<LngLatTuple[]>([]);
|
||||
const [selectedPipes, setSelectedPipes] = useState<SelectedPipe[]>([]);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
const coordinatesRef = useRef(coordinates);
|
||||
const selectedPipesRef = useRef(selectedPipes);
|
||||
|
||||
useEffect(() => {
|
||||
coordinatesRef.current = coordinates;
|
||||
}, [coordinates]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedPipesRef.current = selectedPipes;
|
||||
}, [selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMeasurementLayers(map);
|
||||
setMeasurementSourceData(map, emptyMeasurementFeatureCollection);
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
const syncMeasurementSource = useCallback(
|
||||
(nextCoordinates: LngLatTuple[], nextMode = mode) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeasurementSourceData(
|
||||
map,
|
||||
nextMode !== "segment" && nextCoordinates.length > 0
|
||||
? createMeasurementFeatureCollection(nextCoordinates, nextMode)
|
||||
: emptyMeasurementFeatureCollection
|
||||
);
|
||||
},
|
||||
[mapRef, mode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncMeasurementSource(coordinates, mode);
|
||||
}, [coordinates, mode, syncMeasurementSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMeasurementPipeIds(
|
||||
map,
|
||||
mode === "segment" ? selectedPipes.map((pipe) => pipe.id) : []
|
||||
);
|
||||
}, [mapReady, mapRef, mode, selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !measuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
|
||||
function handleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
if (mode === "segment") {
|
||||
const selectedPipe = getSelectedPipe(activeMap, event);
|
||||
if (selectedPipe) {
|
||||
setSelectedPipes((current) => addUniquePipe(current, selectedPipe));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinate: LngLatTuple = [event.lngLat.lng, event.lngLat.lat];
|
||||
setCoordinates((current) => {
|
||||
if (isSameCoordinate(current[current.length - 1], coordinate)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, coordinate];
|
||||
});
|
||||
}
|
||||
|
||||
map.on("click", handleClick);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
canvas.style.cursor = previousCursor;
|
||||
};
|
||||
}, [mapReady, mapRef, measuring, mode]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setCoordinates([]);
|
||||
setSelectedPipes([]);
|
||||
setMeasuring(false);
|
||||
syncMeasurementSource([]);
|
||||
}, [syncMeasurementSource]);
|
||||
|
||||
const copyResult = useCallback(() => {
|
||||
if (!navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(getResultText(mode, unit, coordinatesRef.current, selectedPipesRef.current));
|
||||
}, [mode, unit]);
|
||||
|
||||
const result = useMemo<WorkbenchMeasurementResult>(() => {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
const areaSquareMeters = mode === "area" ? getPolygonAreaSquareMeters(coordinates) : 0;
|
||||
const segmentCount = Math.max(coordinates.length - 1, 0);
|
||||
const lastSegmentMeters = getLastSegmentDistanceMeters(coordinates);
|
||||
const selectedPipeLengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
|
||||
if (mode === "area") {
|
||||
return {
|
||||
label: "当前面积",
|
||||
value: formatArea(areaSquareMeters, unit),
|
||||
unit: unit === "km" ? "km²" : "m²",
|
||||
metrics: [
|
||||
{ label: "顶点", value: String(coordinates.length) },
|
||||
{ label: "周长", value: `${formatDistance(distanceMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
return {
|
||||
label: "资产长度",
|
||||
value: formatDistance(selectedPipeLengthMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "管段", value: `${selectedPipes.length} 段` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "当前长度",
|
||||
value: formatDistance(distanceMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "段数", value: String(segmentCount) },
|
||||
{ label: "末段", value: `${formatDistance(lastSegmentMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}, [coordinates, mode, selectedPipes, unit]);
|
||||
|
||||
return {
|
||||
measuring,
|
||||
result,
|
||||
hasPoints: mode === "segment" ? selectedPipes.length > 0 : coordinates.length > 0,
|
||||
start: () => setMeasuring(true),
|
||||
stop: () => setMeasuring(false),
|
||||
clear,
|
||||
copyResult
|
||||
};
|
||||
}
|
||||
|
||||
function getResultText(
|
||||
mode: WorkbenchMeasureMode,
|
||||
unit: WorkbenchMeasureUnit,
|
||||
coordinates: LngLatTuple[],
|
||||
selectedPipes: SelectedPipe[]
|
||||
) {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
|
||||
if (mode === "area") {
|
||||
return `面积:${formatArea(getPolygonAreaSquareMeters(coordinates), unit)} ${unit === "km" ? "km²" : "m²"}`;
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
const lengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
return `资产长度:${formatDistance(lengthMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
return `长度:${formatDistance(distanceMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
function getSelectedPipe(map: MapLibreMap, event: MapMouseEvent): SelectedPipe | null {
|
||||
const point = event.point;
|
||||
const features = map.queryRenderedFeatures(
|
||||
[
|
||||
[point.x - SNAP_RADIUS_PIXELS, point.y - SNAP_RADIUS_PIXELS],
|
||||
[point.x + SNAP_RADIUS_PIXELS, point.y + SNAP_RADIUS_PIXELS]
|
||||
],
|
||||
{ layers: SNAP_LAYER_IDS }
|
||||
);
|
||||
|
||||
for (const feature of features) {
|
||||
const selectedPipe = toSelectedPipe(feature);
|
||||
if (selectedPipe) {
|
||||
return selectedPipe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSelectedPipe(feature: MapGeoJSONFeature): SelectedPipe | null {
|
||||
const id = getFeatureId(feature);
|
||||
if (!id || feature.layer.id !== "pipes-flow") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
lengthMeters: getNumericProperty(feature.properties?.length)
|
||||
};
|
||||
}
|
||||
|
||||
function getNumericProperty(value: unknown) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function addUniquePipe(current: SelectedPipe[], next: SelectedPipe) {
|
||||
if (current.some((pipe) => pipe.id === next.id)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, next];
|
||||
}
|
||||
|
||||
function formatDistance(valueMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueMeters / 1000 : valueMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 2 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function formatArea(valueSquareMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueSquareMeters / 1_000_000 : valueSquareMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 3 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MapWorkbenchPage } from "./map-workbench-page";
|
||||
@@ -0,0 +1,979 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
AgentCollapsedRail,
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
type AgentUiResult
|
||||
} from "@/features/agent";
|
||||
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
MapLoadingNotice,
|
||||
MapSourceStatusNotice,
|
||||
showMapNotice,
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core/components/notice";
|
||||
import { MapScaleLine } from "@/features/map/core/components/scale-line";
|
||||
import { MapToolbar, type MapToolbarItem } from "@/features/map/core/components/toolbar";
|
||||
import { MapZoom } from "@/features/map/core/components/zoom";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel";
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
|
||||
createOperationalScheduledConditions
|
||||
} from "./data/scheduled-conditions";
|
||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
import {
|
||||
BASE_LAYER_IDS,
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
MAP_LEGEND_ITEMS,
|
||||
WORKBENCH_LAYER_GROUPS,
|
||||
createLayerControlItems
|
||||
} from "./map/map-control-config";
|
||||
import { waterNetworkToolbarItems } from "./map/toolbar-config";
|
||||
import type { DetailFeature, ScheduledConditionItem, ScheduledConditionRecord, WorkbenchAlert } from "./types";
|
||||
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
|
||||
function formatHeaderDataTime(date: Date) {
|
||||
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(snappedMinutes).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
||||
const nextTick = new Date(date);
|
||||
const nextMinutes =
|
||||
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
|
||||
HEADER_DATA_TIME_STEP_MINUTES;
|
||||
|
||||
nextTick.setMinutes(nextMinutes, 0, 0);
|
||||
|
||||
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
|
||||
}
|
||||
|
||||
export function MapWorkbenchPage() {
|
||||
const hasMapboxToken = Boolean(process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
|
||||
);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||||
conditionId: string;
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
const [activeBaseLayerId, setActiveBaseLayerId] = useState(hasMapboxToken ? "mapbox-light" : "none");
|
||||
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
|
||||
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
|
||||
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
|
||||
const [activeDrawToolId, setActiveDrawToolId] = useState<WorkbenchDrawMode | null>(null);
|
||||
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
||||
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
||||
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
|
||||
const [headerDataTime, setHeaderDataTime] = useState(() => formatHeaderDataTime(new Date()));
|
||||
const baseLayerOptions = useMemo(
|
||||
() =>
|
||||
BASE_LAYER_OPTIONS.map((option) => ({
|
||||
...option,
|
||||
disabled: option.id !== "none" && !hasMapboxToken
|
||||
})),
|
||||
[hasMapboxToken]
|
||||
);
|
||||
|
||||
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
||||
setDetailFeature(feature);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
|
||||
|
||||
handleChange();
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number;
|
||||
|
||||
const refreshHeaderDataTime = () => {
|
||||
const now = new Date();
|
||||
|
||||
setHeaderDataTime(formatHeaderDataTime(now));
|
||||
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
|
||||
};
|
||||
|
||||
refreshHeaderDataTime();
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshScheduledConditions = () => {
|
||||
setScheduledConditions(createOperationalScheduledConditions(new Date()));
|
||||
setScheduledConditionsLoading(false);
|
||||
};
|
||||
|
||||
refreshScheduledConditions();
|
||||
const intervalId = window.setInterval(refreshScheduledConditions, SCHEDULED_CONDITION_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowConditionFeed) {
|
||||
setConditionFeedMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setConditionFeedMounted(false);
|
||||
}, 170);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [shouldShowConditionFeed]);
|
||||
|
||||
const leftPanelOpen = isLargeScreen;
|
||||
const rightPanelOpen = false;
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
impactVisible,
|
||||
onSelectFeature: handleSelectFeature
|
||||
});
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope
|
||||
});
|
||||
const activeAgentUiResults = useMemo(
|
||||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||||
[agent.sessionId, agentUiResults]
|
||||
);
|
||||
const conditionAlerts = useMemo(
|
||||
() => createScheduledConditionAlerts(scheduledConditions),
|
||||
[scheduledConditions]
|
||||
);
|
||||
const runningTickerConditions = useMemo(
|
||||
() =>
|
||||
scheduledConditions.filter(
|
||||
(condition): condition is ScheduledConditionRecord =>
|
||||
condition.kind === "condition" && condition.status === "running"
|
||||
),
|
||||
[scheduledConditions]
|
||||
);
|
||||
const workbenchAlerts = conditionAlerts;
|
||||
|
||||
const drawing = useWorkbenchDrawing({
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode: activeDrawToolId,
|
||||
onModeChange: setActiveDrawToolId
|
||||
});
|
||||
|
||||
const measurement = useWorkbenchMeasurement({
|
||||
mapRef,
|
||||
mapReady,
|
||||
mode: activeMeasureModeId,
|
||||
unit: activeMeasureUnitId
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)"
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 420,
|
||||
damping: 30,
|
||||
mass: 0.75
|
||||
}
|
||||
};
|
||||
const taskTickerExitState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0 }
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(4px)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: [0.5, 0, 0.2, 1] as const
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyBaseLayerVisibility(mapRef.current, activeBaseLayerId);
|
||||
}, [activeBaseLayerId, mapReady, mapRef]);
|
||||
|
||||
const toggleToolbarTool = useCallback((toolId: ToolbarToolId) => {
|
||||
setActiveToolId((current) => {
|
||||
return current === toolId ? null : toolId;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConditionFocusRequestHandled = useCallback((requestId: number) => {
|
||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
||||
}, []);
|
||||
|
||||
const toolbarItems = useMemo<MapToolbarItem[]>(
|
||||
() =>
|
||||
waterNetworkToolbarItems.map((item) => ({
|
||||
...item,
|
||||
active: activeToolId === item.id,
|
||||
disabled: !mapReady,
|
||||
onClick: () => toggleToolbarTool(item.id as ToolbarToolId)
|
||||
})),
|
||||
[activeToolId, mapReady, toggleToolbarTool]
|
||||
);
|
||||
|
||||
const layerControlItems = useMemo(() => createLayerControlItems(layerVisibility), [layerVisibility]);
|
||||
function handleToggleLayer(layerGroupId: string, visible: boolean) {
|
||||
const map = mapRef.current;
|
||||
|
||||
setLayerVisibility((current) => ({ ...current, [layerGroupId]: visible }));
|
||||
if (layerGroupId === "simulation") {
|
||||
setImpactVisible(visible);
|
||||
}
|
||||
|
||||
if (!map || !mapReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
WORKBENCH_LAYER_GROUPS[layerGroupId]?.forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleExportView(preset: ExportViewPreset) {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) {
|
||||
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法导出视图。" });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLongEdge = getExportTargetLongEdge(preset);
|
||||
const exportLabel = getExportPresetLabel(preset);
|
||||
|
||||
showMapNotice({
|
||||
id: "map-view-export",
|
||||
tone: "loading",
|
||||
title: "正在导出",
|
||||
message: `正在生成${exportLabel}地图截图...`,
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await exportMapViewImage(map, {
|
||||
scale: preset === "current" ? 1 : undefined,
|
||||
targetLongEdge
|
||||
});
|
||||
showMapNotice({
|
||||
id: "map-view-export",
|
||||
tone: "success",
|
||||
message: `已导出 ${result.width} × ${result.height} PNG。`
|
||||
});
|
||||
} catch {
|
||||
showMapNotice({
|
||||
id: "map-view-export",
|
||||
tone: "error",
|
||||
title: "导出失败",
|
||||
message: "当前底图或瓦片可能限制了画布导出。"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshTiles() {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) {
|
||||
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法刷新瓦片。" });
|
||||
return;
|
||||
}
|
||||
|
||||
map.triggerRepaint();
|
||||
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
|
||||
}
|
||||
|
||||
function handleShowDataStatus() {
|
||||
if (sourceStatuses.length === 0) {
|
||||
showMapNotice({ tone: "info", message: "数据源状态正在初始化。" });
|
||||
return;
|
||||
}
|
||||
|
||||
showMapNotice({
|
||||
tone: sourceStatuses.some((sourceStatus) => sourceStatus.status === "offline") ? "warning" : "info",
|
||||
title: "数据状态",
|
||||
message: sourceStatuses.map((sourceStatus) => `${sourceStatus.sourceName}:${sourceStatus.status}`).join(" · "),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
|
||||
function handleShowShortcuts() {
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "快捷键",
|
||||
message: "双击完成线/面绘制;圆形标注先点圆心,再点半径;Esc 可作为后续快捷键扩展。",
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectScenario(scenarioId: string) {
|
||||
const scenario = WORKBENCH_SCENARIOS.find((item) => item.id === scenarioId);
|
||||
setActiveScenarioId(scenarioId);
|
||||
showMapNotice({
|
||||
tone: "success",
|
||||
title: "方案已切换",
|
||||
message: `当前方案:${scenario?.name ?? scenarioId}。地图模拟结果暂未自动切换。`
|
||||
});
|
||||
}
|
||||
|
||||
function handlePreviewScenario() {
|
||||
setImpactVisible(true);
|
||||
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
||||
|
||||
if (mapRef.current && mapReady) {
|
||||
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
|
||||
if (mapRef.current?.getLayer(layerId)) {
|
||||
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "预览影响范围",
|
||||
message: "已打开模拟结果图层,可在右侧图层面板继续调整显示。"
|
||||
});
|
||||
}
|
||||
|
||||
function handleCompareScenario() {
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "方案比较",
|
||||
message: "候选方案比较将汇总影响范围、阀门操作与保供风险。"
|
||||
});
|
||||
}
|
||||
|
||||
function handleExportScenarioReport() {
|
||||
showMapNotice({
|
||||
tone: "success",
|
||||
title: "场景报告",
|
||||
message: "已生成当前场景报告任务,可继续导出地图截图或配置。"
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAnalyzeAlertsWithAgent() {
|
||||
if (workbenchAlerts.length === 0) {
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "当前无待复核工况",
|
||||
message: "当前没有需要汇总的工况提醒。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent.streaming) {
|
||||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
||||
return;
|
||||
}
|
||||
|
||||
agent.expandPanel();
|
||||
|
||||
try {
|
||||
await agent.submitPrompt(
|
||||
createAlertQueueConversationPrompt({
|
||||
alerts: workbenchAlerts,
|
||||
conditions: scheduledConditions
|
||||
})
|
||||
);
|
||||
|
||||
showMapNotice({
|
||||
tone: "success",
|
||||
title: "已发起工况汇总",
|
||||
message: "已将待复核工况发送到 Agent。"
|
||||
});
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "工况汇总发起失败",
|
||||
message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAlert(alert: WorkbenchAlert) {
|
||||
const conditionId = getScheduledConditionIdFromAlertId(alert.id);
|
||||
if (conditionId) {
|
||||
const condition = scheduledConditions.find((item) => item.id === conditionId);
|
||||
if (!condition) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "工况任务未找到",
|
||||
message: `未找到提醒关联的工况任务:${conditionId}。`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedVisible(true);
|
||||
setActiveToolId(null);
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: alert.title,
|
||||
message: alert.description
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAgentUiEnvelope(payload: UIEnvelopePayload, sessionId: string) {
|
||||
const { envelope } = payload;
|
||||
|
||||
if (envelope.kind === "map_action") {
|
||||
await handleAgentMapAction(envelope.action, envelope.params, sessionId, envelope.fallbackText);
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentUiResults((current) => [
|
||||
...current.slice(-5),
|
||||
{
|
||||
id: payload.envelope_id || `agent-ui-${Date.now().toString(36)}`,
|
||||
sessionId,
|
||||
createdAt: payload.created_at,
|
||||
envelope
|
||||
}
|
||||
]);
|
||||
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: envelope.kind === "chart" ? "Agent 图表已渲染" : "Agent 面板已渲染",
|
||||
message:
|
||||
envelope.fallbackText ??
|
||||
(envelope.kind === "chart" ? "已在 Agent 面板显示可信图表。" : "已在 Agent 面板显示可信组件。")
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) {
|
||||
const trustedAction = toTrustedMapAction(action, params, fallbackText);
|
||||
if (!trustedAction) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "地图动作已降级",
|
||||
message: fallbackText ?? "Agent 地图动作参数未通过前端校验。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trustedAction.type === "zoom_to_map") {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) {
|
||||
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法执行 Agent 缩放动作。" });
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
duration: 500
|
||||
});
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent 地图定位",
|
||||
message: trustedAction.fallbackText ?? "已根据 Agent 建议缩放到目标位置。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trustedAction.type === "locate_features") {
|
||||
fitNetworkBounds();
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent 资产定位",
|
||||
message:
|
||||
trustedAction.fallbackText ??
|
||||
(trustedAction.featureIds.length > 0
|
||||
? `已接收 ${trustedAction.featureIds.length} 个目标资产,当前先回到全网视图。`
|
||||
: "已根据 Agent 建议回到全网视图。")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trustedAction.type === "apply_layer_style") {
|
||||
if (trustedAction.layerGroupId && typeof trustedAction.visible === "boolean") {
|
||||
handleToggleLayer(trustedAction.layerGroupId, trustedAction.visible);
|
||||
}
|
||||
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent 图层样式",
|
||||
message: trustedAction.fallbackText ?? "已接收 Agent 图层样式建议,当前仅执行受控图层显隐。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.resolveRenderRef(trustedAction.renderRef, sessionId);
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent 节点渲染",
|
||||
message: trustedAction.fallbackText ?? "已验证 render_ref,节点分类渲染器将在后续接入。"
|
||||
});
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
title: "render_ref 解析失败",
|
||||
message: error instanceof Error ? error.message : "无法读取 Agent 渲染结果。"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportConfig() {
|
||||
const config = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
activeBaseLayerId,
|
||||
layerVisibility,
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "drainage-network-map-config.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showMapNotice({ tone: "success", message: "当前地图配置已导出。" });
|
||||
}
|
||||
|
||||
const toolbarPanelProps: ToolbarPanelProps = {
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId,
|
||||
measuring: measurement.measuring,
|
||||
measureResult: measurement.result,
|
||||
measureEnabled: mapReady,
|
||||
hasMeasurePoints: measurement.hasPoints,
|
||||
activeDrawToolId,
|
||||
drawingEnabled: mapReady,
|
||||
drawingAnnotations: drawing.annotations,
|
||||
canUndoDrawing: drawing.canUndo,
|
||||
canRedoDrawing: drawing.canRedo,
|
||||
canDeleteSelectedDrawing: drawing.canDeleteSelected,
|
||||
hasDrawingFeatures: drawing.hasFeatures,
|
||||
layerItems: layerControlItems,
|
||||
legendItems: MAP_LEGEND_ITEMS,
|
||||
baseLayerOptions,
|
||||
activeBaseLayerId,
|
||||
onSelectMeasureMode: setActiveMeasureModeId,
|
||||
onSelectMeasureUnit: setActiveMeasureUnitId,
|
||||
onStartMeasure: measurement.start,
|
||||
onStopMeasure: measurement.stop,
|
||||
onClearMeasure: measurement.clear,
|
||||
onCopyMeasure: measurement.copyResult,
|
||||
onSelectDrawTool: setActiveDrawToolId,
|
||||
onUndoDrawing: drawing.undo,
|
||||
onRedoDrawing: drawing.redo,
|
||||
onDeleteSelectedDrawing: drawing.deleteSelected,
|
||||
onClearDrawing: drawing.clear,
|
||||
onExportAnnotations: drawing.exportGeoJSON,
|
||||
onSelectBaseLayer: setActiveBaseLayerId,
|
||||
onToggleLayer: handleToggleLayer,
|
||||
onExportView: handleExportView,
|
||||
onRefreshTiles: handleRefreshTiles,
|
||||
onShowDataStatus: handleShowDataStatus,
|
||||
onShowShortcuts: handleShowShortcuts,
|
||||
onExportConfig: handleExportConfig
|
||||
};
|
||||
const agentPanelProps: Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse"> = {
|
||||
personaState: agent.personaState,
|
||||
sessionTitle: agent.sessionTitle,
|
||||
sessionHistory: agent.sessionHistory,
|
||||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||||
activeSessionId: agent.sessionId,
|
||||
statusLabel: agent.statusLabel,
|
||||
streaming: agent.streaming,
|
||||
messages: agent.messages,
|
||||
streamRenderState: agent.streamRenderState,
|
||||
modelOptions: agent.modelOptions,
|
||||
selectedModel: agent.selectedModel,
|
||||
uiResults: activeAgentUiResults,
|
||||
onRefreshHistory: agent.refreshSessionHistory,
|
||||
onStartNewSession: agent.startNewSession,
|
||||
onLoadHistorySession: agent.loadHistorySession,
|
||||
onRenameHistorySession: agent.renameHistorySession,
|
||||
onDeleteHistorySession: agent.deleteHistorySession,
|
||||
onSelectModel: agent.setSelectedModel,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
onReplyQuestion: agent.replyQuestion,
|
||||
onRejectQuestion: agent.rejectQuestion
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900">
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="排水管网地图"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="DrainFlow 1.0.0"
|
||||
scenarios={WORKBENCH_SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
conditionFeedVisible={shouldShowConditionFeed}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={() => {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
setActiveToolId(null);
|
||||
}}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onExportConfig={handleExportConfig}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
|
||||
{agent.panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
collapsing={agent.panelCollapsing}
|
||||
onCollapse={agent.collapsePanel}
|
||||
/>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
onExpand={agent.expandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-[62px] top-24 z-20 hidden lg:block",
|
||||
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
|
||||
)}
|
||||
>
|
||||
<ScheduledConditionFeed
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={agent.expandPanel}
|
||||
onLoadHistorySession={agent.loadHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{agent.mobileOpen ? (
|
||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
collapsing={agent.mobilePanelCollapsing}
|
||||
onCollapse={agent.closeMobilePanel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MobileAgentToggle
|
||||
open={agent.mobileOpen}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
streaming={agent.streaming}
|
||||
onOpen={agent.openMobilePanel}
|
||||
onClose={agent.closeMobilePanel}
|
||||
/>
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
||||
<div className="flex items-end gap-2 pr-2">
|
||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||
</div>
|
||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
key="agent-task-ticker-shell"
|
||||
className="fixed left-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? false
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(6px)"
|
||||
}
|
||||
}
|
||||
animate={taskTickerEnterState}
|
||||
exit={taskTickerExitState}
|
||||
>
|
||||
<AgentTaskTicker
|
||||
className="w-full"
|
||||
conditions={runningTickerConditions}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
|
||||
{!agent.mobileOpen ? (
|
||||
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full justify-center" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||||
|
||||
{!mapReady && !mapError ? <MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" /> : null}
|
||||
{!hasMapboxToken ? (
|
||||
<MapSourceStatusNotice
|
||||
sourceName="Mapbox 底图"
|
||||
status="degraded"
|
||||
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
|
||||
/>
|
||||
) : null}
|
||||
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
|
||||
<MapSourceStatusNotice
|
||||
key={sourceStatus.id}
|
||||
id={`map-source-runtime-${sourceStatus.id}`}
|
||||
sourceName={sourceStatus.sourceName}
|
||||
status={sourceStatus.status}
|
||||
message={sourceStatus.message}
|
||||
/>
|
||||
))}
|
||||
{mapError ? <MapErrorNotice message={mapError} /> : null}
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function isNoticeSourceStatus(sourceStatus: {
|
||||
status: string;
|
||||
}): sourceStatus is { id: string; sourceName: string; status: MapSourceStatus; message: string } {
|
||||
return sourceStatus.status === "online" || sourceStatus.status === "degraded" || sourceStatus.status === "offline";
|
||||
}
|
||||
|
||||
function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
|
||||
BASE_LAYER_IDS.forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", activeBaseLayerId === layerId ? "visible" : "none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getExportTargetLongEdge(preset: ExportViewPreset) {
|
||||
if (preset === "4k") {
|
||||
return 3840;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getExportPresetLabel(preset: ExportViewPreset) {
|
||||
if (preset === "4k") {
|
||||
return " 4K ";
|
||||
}
|
||||
|
||||
return "当前分辨率";
|
||||
}
|
||||
|
||||
function createScheduledConditionAlerts(conditions: ScheduledConditionItem[]): WorkbenchAlert[] {
|
||||
return conditions
|
||||
.filter(isAlertCondition)
|
||||
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
|
||||
.map((condition) => {
|
||||
return {
|
||||
id: `scheduled-condition-alert-${condition.id}`,
|
||||
title: `待复核:${condition.title}`,
|
||||
description: condition.detail ?? condition.summary,
|
||||
time: formatScheduledConditionTime(condition.scheduledAt)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isAlertCondition(condition: ScheduledConditionItem) {
|
||||
return condition.kind === "condition" && condition.status === "error";
|
||||
}
|
||||
|
||||
function getScheduledConditionTimestamp(condition: ScheduledConditionItem) {
|
||||
const scheduledAt = Date.parse(condition.scheduledAt);
|
||||
return Number.isNaN(scheduledAt) ? condition.updatedAt : scheduledAt;
|
||||
}
|
||||
|
||||
function formatScheduledConditionTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
function getScheduledConditionIdFromAlertId(alertId: string) {
|
||||
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
function MobileAgentToggle({
|
||||
open,
|
||||
personaState,
|
||||
statusLabel,
|
||||
streaming,
|
||||
onOpen,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
streaming: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (open) {
|
||||
return (
|
||||
<div className="absolute bottom-20 right-3 z-40 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭 Agent 面板"
|
||||
title="关闭 Agent 面板"
|
||||
onClick={onClose}
|
||||
className="agent-panel-icon-button grid h-10 w-10 place-items-center rounded-2xl text-slate-700 transition"
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
"grid h-12 w-12 place-items-center text-violet-700",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<AgentPersona
|
||||
className="h-10 w-10"
|
||||
fallbackClassName="h-10 w-10"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
||||
|
||||
export const SIMULATION_SOURCE_IDS = {
|
||||
impactArea: "simulation-impact-area",
|
||||
annotations: "simulation-annotations"
|
||||
} as const;
|
||||
|
||||
export const simulationSources = {
|
||||
impactArea: {
|
||||
type: "geojson",
|
||||
data: impactAreaPolygon
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
annotations: {
|
||||
type: "geojson",
|
||||
data: annotationPoints
|
||||
} satisfies GeoJSONSourceSpecification
|
||||
};
|
||||
|
||||
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "simulation-impact-fill",
|
||||
type: "fill",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"fill-color": "#ef4444",
|
||||
"fill-opacity": 0.18
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-outline",
|
||||
type: "line",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"line-color": "#ef4444",
|
||||
"line-width": 1.5,
|
||||
"line-dasharray": [2, 2],
|
||||
"line-opacity": 0.72
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-halo",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": "#fef2f2",
|
||||
"circle-radius": 18,
|
||||
"circle-stroke-color": "#ef4444",
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-point",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": "#ef4444",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-pipe-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "pipe-label"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#ffffff",
|
||||
"text-halo-color": "#2563eb",
|
||||
"text-halo-width": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "tooltip"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#dc2626",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-district-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "district"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#334155",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
layout: {
|
||||
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#0f172a",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const simulationLayerIds = simulationAnnotationLayers.map((layer) => layer.id);
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fitNetworkBounds } from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
describe("fitNetworkBounds", () => {
|
||||
it("fits the global WebMercator bbox through MapLibre lng/lat bounds", () => {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({
|
||||
clientWidth: 1280,
|
||||
clientHeight: 720,
|
||||
width: 1280,
|
||||
height: 720
|
||||
})
|
||||
} as unknown as MapLibreMap;
|
||||
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
|
||||
expect(fitBounds).toHaveBeenCalledTimes(1);
|
||||
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
|
||||
|
||||
expect(bounds[0][0]).toBeCloseTo(121.351633, 6);
|
||||
expect(bounds[0][1]).toBeCloseTo(30.810505, 6);
|
||||
expect(bounds[1][0]).toBeCloseTo(121.772485, 6);
|
||||
expect(bounds[1][1]).toBeCloseTo(31.007214, 6);
|
||||
expect(options).toMatchObject({
|
||||
duration: 600,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
});
|
||||
expect(options).not.toHaveProperty("zoom");
|
||||
expect(options).not.toHaveProperty("maxZoom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
type WebMercatorBbox = readonly [number, number, number, number];
|
||||
|
||||
export type NetworkViewParams = {
|
||||
bbox3857: WebMercatorBbox;
|
||||
};
|
||||
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const NO_PADDING: PaddingOptions = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
return {
|
||||
top: 88,
|
||||
left: leftPanelOpen ? 530 : 88,
|
||||
right: rightPanelOpen ? 430 : 120,
|
||||
bottom: 96
|
||||
};
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
map: MapLibreMap,
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
): PaddingOptions {
|
||||
return getResponsivePadding(map, getWorkbenchPadding(leftPanelOpen, rightPanelOpen));
|
||||
}
|
||||
|
||||
export function fitNetworkBounds(
|
||||
map: MapLibreMap,
|
||||
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
|
||||
padding: PaddingOptions = NO_PADDING
|
||||
) {
|
||||
map.fitBounds(
|
||||
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
|
||||
{ padding: getResponsivePadding(map, padding), duration: 600 }
|
||||
);
|
||||
}
|
||||
|
||||
function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const canvas = map.getCanvas();
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const horizontalPadding = padding.left + padding.right;
|
||||
const verticalPadding = padding.top + padding.bottom;
|
||||
const maxHorizontalPadding = width * 0.58;
|
||||
const maxVerticalPadding = height * 0.58;
|
||||
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
|
||||
const verticalScale = verticalPadding > 0 ? maxVerticalPadding / verticalPadding : 1;
|
||||
const scale = Math.min(1, horizontalScale, verticalScale);
|
||||
|
||||
if (scale >= 1) {
|
||||
return padding;
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.round(padding.top * scale),
|
||||
right: Math.round(padding.right * scale),
|
||||
bottom: Math.round(padding.bottom * scale),
|
||||
left: Math.round(padding.left * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
return [
|
||||
webMercatorToLngLat(minX, minY),
|
||||
webMercatorToLngLat(maxX, maxY)
|
||||
];
|
||||
}
|
||||
|
||||
function webMercatorToLngLat(x: number, y: number): [number, number] {
|
||||
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
|
||||
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
|
||||
|
||||
return [lng, lat];
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import type {
|
||||
FilterSpecification,
|
||||
GeoJSONSource,
|
||||
GeoJSONSourceSpecification,
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||
import { createCircleCoordinates } from "./geo";
|
||||
|
||||
export const DRAWING_SOURCE_IDS = {
|
||||
features: "workbench-drawing-features",
|
||||
draft: "workbench-drawing-draft"
|
||||
} as const;
|
||||
|
||||
export const DRAWING_INTERACTIVE_LAYER_IDS = [
|
||||
"workbench-drawing-selected-polygon-outline",
|
||||
"workbench-drawing-selected-line",
|
||||
"workbench-drawing-selected-point",
|
||||
"workbench-drawing-polygon-fill",
|
||||
"workbench-drawing-polygon-outline",
|
||||
"workbench-drawing-line",
|
||||
"workbench-drawing-point"
|
||||
];
|
||||
|
||||
export type DrawingFeatureProperties = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "point" | "line" | "polygon" | "circle";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DrawingFeatureCollection = FeatureCollection<Geometry, DrawingFeatureProperties>;
|
||||
|
||||
export const emptyDrawingFeatureCollection: DrawingFeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
};
|
||||
|
||||
export const drawingSources = {
|
||||
features: {
|
||||
type: "geojson",
|
||||
data: emptyDrawingFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
draft: {
|
||||
type: "geojson",
|
||||
data: emptyDrawingFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification
|
||||
};
|
||||
|
||||
export const drawingLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "workbench-drawing-polygon-fill",
|
||||
type: "fill",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": "#2563eb",
|
||||
"fill-opacity": 0.16
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-polygon-outline",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"line-color": "#2563eb",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.82
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": "#2563eb",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-point",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: {
|
||||
"circle-color": "#2563eb",
|
||||
"circle-radius": 6,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-polygon-outline",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"line-color": "#ff7a45",
|
||||
"line-width": 4,
|
||||
"line-opacity": 0.86
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"line-color": "#ff7a45",
|
||||
"line-width": 6,
|
||||
"line-opacity": 0.72
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-selected-point",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.features,
|
||||
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
|
||||
paint: {
|
||||
"circle-color": "#ff7a45",
|
||||
"circle-radius": 10,
|
||||
"circle-opacity": 0.2,
|
||||
"circle-stroke-color": "#ff7a45",
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-polygon-fill",
|
||||
type: "fill",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": "#ff7a45",
|
||||
"fill-opacity": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-line",
|
||||
type: "line",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
|
||||
paint: {
|
||||
"line-color": "#ff7a45",
|
||||
"line-width": 2,
|
||||
"line-dasharray": [2, 2],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-drawing-draft-vertex",
|
||||
type: "circle",
|
||||
source: DRAWING_SOURCE_IDS.draft,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: {
|
||||
"circle-color": "#ff7a45",
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1.5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureDrawingLayers(map: {
|
||||
getSource: (id: string) => unknown;
|
||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||
getLayer: (id: string) => unknown;
|
||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
|
||||
}) {
|
||||
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
|
||||
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
|
||||
}
|
||||
|
||||
if (!map.getSource(DRAWING_SOURCE_IDS.draft)) {
|
||||
map.addSource(DRAWING_SOURCE_IDS.draft, drawingSources.draft);
|
||||
}
|
||||
|
||||
drawingLayers.forEach((layer) => {
|
||||
if (!map.getLayer(layer.id)) {
|
||||
map.addLayer(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setDrawingSourceData(
|
||||
map: { getSource: (id: string) => unknown },
|
||||
sourceId: string,
|
||||
data: DrawingFeatureCollection
|
||||
) {
|
||||
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
|
||||
source?.setData(data);
|
||||
}
|
||||
|
||||
export function setSelectedDrawingFeatureId(
|
||||
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
|
||||
featureId: string | null
|
||||
) {
|
||||
const id = featureId ?? "";
|
||||
|
||||
[
|
||||
["workbench-drawing-selected-polygon-outline", "Polygon"],
|
||||
["workbench-drawing-selected-line", "LineString"],
|
||||
["workbench-drawing-selected-point", "Point"]
|
||||
].forEach(([layerId, geometryType]) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setFilter(layerId, ["all", ["==", ["geometry-type"], geometryType], ["==", ["get", "id"], id]]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createPointFeature(
|
||||
id: string,
|
||||
coordinates: [number, number],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "point"),
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates
|
||||
} satisfies Point
|
||||
};
|
||||
}
|
||||
|
||||
export function createLineFeature(
|
||||
id: string,
|
||||
coordinates: [number, number][],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "line"),
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates
|
||||
} satisfies LineString
|
||||
};
|
||||
}
|
||||
|
||||
export function createPolygonFeature(
|
||||
id: string,
|
||||
coordinates: [number, number][],
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "polygon"),
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [[...coordinates, coordinates[0]]]
|
||||
} satisfies Polygon
|
||||
};
|
||||
}
|
||||
|
||||
export function createCircleFeature(
|
||||
id: string,
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] {
|
||||
const coordinates = createCircleCoordinates(center, radiusMeters);
|
||||
|
||||
return {
|
||||
type: "Feature",
|
||||
id,
|
||||
properties: createDrawingProperties(id, label, "circle"),
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [coordinates]
|
||||
} satisfies Polygon
|
||||
};
|
||||
}
|
||||
|
||||
function createDrawingProperties(
|
||||
id: string,
|
||||
label: string,
|
||||
kind: DrawingFeatureProperties["kind"]
|
||||
): DrawingFeatureProperties {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createCircleFeature,
|
||||
createLineFeature,
|
||||
createPolygonFeature,
|
||||
type DrawingFeatureCollection
|
||||
} from "./drawing-layers";
|
||||
import { getDistanceMeters, type LngLatTuple } from "./geo";
|
||||
|
||||
export type WorkbenchDrawMode = "point" | "line" | "polygon" | "circle";
|
||||
|
||||
export type DrawingDraftState =
|
||||
| {
|
||||
mode: "line" | "polygon";
|
||||
coordinates: LngLatTuple[];
|
||||
}
|
||||
| {
|
||||
mode: "circle";
|
||||
center: LngLatTuple;
|
||||
edge?: LngLatTuple;
|
||||
};
|
||||
|
||||
export function createDrawingId(mode: WorkbenchDrawMode, index: number) {
|
||||
return `${mode}-${index.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
export function createDrawingLabel(mode: WorkbenchDrawMode, index: number) {
|
||||
return `${getDrawingKindLabel(mode)} ${index.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function createFeatureFromDraft(
|
||||
id: string,
|
||||
draft: DrawingDraftState,
|
||||
label: string
|
||||
): DrawingFeatureCollection["features"][number] | null {
|
||||
if (draft.mode === "line") {
|
||||
return createLineFeature(id, draft.coordinates, label);
|
||||
}
|
||||
|
||||
if (draft.mode === "polygon") {
|
||||
return createPolygonFeature(id, draft.coordinates, label);
|
||||
}
|
||||
|
||||
if (draft.mode !== "circle" || !draft.edge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createCircleFeature(id, draft.center, getDistanceMeters(draft.center, draft.edge), label);
|
||||
}
|
||||
|
||||
export function getDraftPointCount(draft: DrawingDraftState) {
|
||||
if (draft.mode === "circle") {
|
||||
return draft.edge ? 2 : 1;
|
||||
}
|
||||
|
||||
return draft.coordinates.length;
|
||||
}
|
||||
|
||||
export function getDrawingKindLabel(kind: WorkbenchDrawMode) {
|
||||
if (kind === "point") {
|
||||
return "点标注";
|
||||
}
|
||||
|
||||
if (kind === "line") {
|
||||
return "线标注";
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
return "圆形标注";
|
||||
}
|
||||
|
||||
return "范围标注";
|
||||
}
|
||||
|
||||
export function getVisibleFeatureCollection(
|
||||
featureCollection: DrawingFeatureCollection,
|
||||
hiddenFeatureIds: Set<string>
|
||||
): DrawingFeatureCollection {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: featureCollection.features.filter((feature) => !hiddenFeatureIds.has(feature.properties.id))
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDrawingTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
||||
|
||||
const MAX_EXPORT_DIMENSION = 4096;
|
||||
const EXPORT_IDLE_TIMEOUT_MS = 6000;
|
||||
|
||||
export type ExportMapViewOptions = {
|
||||
scale?: number;
|
||||
targetLongEdge?: number;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename }: ExportMapViewOptions = {}) {
|
||||
const canvas = map.getCanvas();
|
||||
const sourceWidth = canvas.clientWidth || canvas.width;
|
||||
const sourceHeight = canvas.clientHeight || canvas.height;
|
||||
|
||||
if (!sourceWidth || !sourceHeight) {
|
||||
throw new Error("地图画布尺寸不可用。");
|
||||
}
|
||||
|
||||
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
|
||||
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
|
||||
const exportWidth = Math.round(sourceWidth * exportScale);
|
||||
const exportHeight = Math.round(sourceHeight * exportScale);
|
||||
const container = createExportContainer(exportWidth, exportHeight);
|
||||
const style = cloneMapStyle(map.getStyle());
|
||||
const bounds = map.getBounds();
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
const exportMap = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center: map.getCenter(),
|
||||
zoom: map.getZoom(),
|
||||
pitch: map.getPitch(),
|
||||
bearing: map.getBearing(),
|
||||
preserveDrawingBuffer: true,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0
|
||||
});
|
||||
|
||||
try {
|
||||
await onceMapEvent(exportMap, "load");
|
||||
exportMap.fitBounds(bounds, {
|
||||
padding: 0,
|
||||
duration: 0
|
||||
});
|
||||
await waitForMapIdle(exportMap);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
|
||||
link.href = exportMap.getCanvas().toDataURL("image/png");
|
||||
link.click();
|
||||
|
||||
return {
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
scale: exportScale
|
||||
};
|
||||
} finally {
|
||||
exportMap.remove();
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function createExportContainer(width: number, height: number) {
|
||||
const container = document.createElement("div");
|
||||
container.style.position = "fixed";
|
||||
container.style.left = "-10000px";
|
||||
container.style.top = "0";
|
||||
container.style.width = `${width}px`;
|
||||
container.style.height = `${height}px`;
|
||||
container.style.pointerEvents = "none";
|
||||
container.style.opacity = "0";
|
||||
return container;
|
||||
}
|
||||
|
||||
function cloneMapStyle(style: StyleSpecification) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(style);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
|
||||
}
|
||||
|
||||
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
map.once(eventName, () => resolve());
|
||||
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMapIdle(map: MapLibreMap) {
|
||||
return Promise.race([
|
||||
onceMapEvent(map, "idle"),
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
|
||||
if (targetLongEdge === 3840) {
|
||||
return "4k";
|
||||
}
|
||||
|
||||
return `${scale.toFixed(1)}x`;
|
||||
}
|
||||
|
||||
function getDefaultExportFilename(label: string) {
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
return `drainage-network-view-${label}-${timestamp}.png`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MapGeoJSONFeature } from "maplibre-gl";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { formatValue } from "../utils/format-value";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
export function getFeatureId(feature: MapGeoJSONFeature) {
|
||||
const raw = feature.properties?.id ?? feature.id;
|
||||
return raw === undefined || raw === null ? "" : String(raw);
|
||||
}
|
||||
|
||||
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
|
||||
const isPipe = feature.sourceLayer === SOURCE_LAYERS.pipes;
|
||||
const id = getFeatureId(feature);
|
||||
const diameter = feature.properties?.diameter;
|
||||
const length = feature.properties?.length;
|
||||
const demand = feature.properties?.demand;
|
||||
|
||||
return {
|
||||
id,
|
||||
layer: isPipe ? "pipes" : "junctions",
|
||||
title: isPipe ? `管线 ${id || "未命名"}` : `节点 ${id || "未命名"}`,
|
||||
subtitle: isPipe
|
||||
? `DN${formatValue(diameter)} · ${formatValue(length)} m`
|
||||
: `需水量 ${formatValue(demand)} · 高程 ${formatValue(feature.properties?.elevation)} m`,
|
||||
properties: feature.properties ?? {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export type LngLatTuple = [number, number];
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const SAME_COORDINATE_EPSILON = 0.0000001;
|
||||
|
||||
export function getDistanceMeters(from: LngLatTuple, to: LngLatTuple) {
|
||||
const fromLatitude = toRadians(from[1]);
|
||||
const toLatitude = toRadians(to[1]);
|
||||
const deltaLatitude = toRadians(to[1] - from[1]);
|
||||
const deltaLongitude = toRadians(to[0] - from[0]);
|
||||
const a =
|
||||
Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function getLineDistanceMeters(coordinates: LngLatTuple[]) {
|
||||
return coordinates.reduce((total, coordinate, index) => {
|
||||
if (index === 0) {
|
||||
return total;
|
||||
}
|
||||
|
||||
return total + getDistanceMeters(coordinates[index - 1], coordinate);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getLastSegmentDistanceMeters(coordinates: LngLatTuple[]) {
|
||||
if (coordinates.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getDistanceMeters(coordinates[coordinates.length - 2], coordinates[coordinates.length - 1]);
|
||||
}
|
||||
|
||||
export function getPolygonAreaSquareMeters(coordinates: LngLatTuple[]) {
|
||||
if (coordinates.length < 3) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const closedCoordinates = [...coordinates, coordinates[0]];
|
||||
const area = closedCoordinates.slice(0, -1).reduce((total, coordinate, index) => {
|
||||
const nextCoordinate = closedCoordinates[index + 1];
|
||||
return (
|
||||
total +
|
||||
toRadians(nextCoordinate[0] - coordinate[0]) *
|
||||
(2 + Math.sin(toRadians(coordinate[1])) + Math.sin(toRadians(nextCoordinate[1])))
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return Math.abs((area * EARTH_RADIUS_METERS * EARTH_RADIUS_METERS) / 2);
|
||||
}
|
||||
|
||||
export function createCircleCoordinates(center: LngLatTuple, radiusMeters: number, steps = 48) {
|
||||
const angularDistance = radiusMeters / EARTH_RADIUS_METERS;
|
||||
const centerLongitude = toRadians(center[0]);
|
||||
const centerLatitude = toRadians(center[1]);
|
||||
const coordinates: LngLatTuple[] = [];
|
||||
|
||||
for (let index = 0; index <= steps; index += 1) {
|
||||
const bearing = (2 * Math.PI * index) / steps;
|
||||
const latitude = Math.asin(
|
||||
Math.sin(centerLatitude) * Math.cos(angularDistance) +
|
||||
Math.cos(centerLatitude) * Math.sin(angularDistance) * Math.cos(bearing)
|
||||
);
|
||||
const longitude =
|
||||
centerLongitude +
|
||||
Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(centerLatitude),
|
||||
Math.cos(angularDistance) - Math.sin(centerLatitude) * Math.sin(latitude)
|
||||
);
|
||||
|
||||
coordinates.push([toDegrees(longitude), toDegrees(latitude)]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
export function isSameCoordinate(previous: LngLatTuple | undefined, next: LngLatTuple) {
|
||||
if (!previous) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(previous[0] - next[0]) < SAME_COORDINATE_EPSILON &&
|
||||
Math.abs(previous[1] - next[1]) < SAME_COORDINATE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function toRadians(value: number) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDegrees(value: number) {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
export const waterNetworkLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "pipes-casing",
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: 9,
|
||||
paint: {
|
||||
"line-color": "rgba(255,255,255,0.86)",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
9,
|
||||
1,
|
||||
13,
|
||||
2.4,
|
||||
17,
|
||||
["+", 5, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
|
||||
],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "pipes-flow",
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: 9,
|
||||
paint: {
|
||||
"line-color": [
|
||||
"case",
|
||||
["==", ["get", "status"], "Closed"],
|
||||
"#9ca3af",
|
||||
[">=", ["coalesce", ["get", "diameter"], 0], 600],
|
||||
"#0477bf",
|
||||
[">=", ["coalesce", ["get", "diameter"], 0], 300],
|
||||
"#0aa6a6",
|
||||
"#3dbf7f"
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
9,
|
||||
0.8,
|
||||
13,
|
||||
1.8,
|
||||
17,
|
||||
["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]]
|
||||
],
|
||||
"line-opacity": 0.82
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "pipes-risk-glow",
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: 12,
|
||||
filter: [">=", ["coalesce", ["get", "diameter"], 0], 600],
|
||||
paint: {
|
||||
"line-color": "#ff7a45",
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5],
|
||||
"line-blur": 2.5,
|
||||
"line-opacity": 0.22
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "pipes-hover",
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: 9,
|
||||
filter: ["==", ["get", "id"], ""],
|
||||
paint: {
|
||||
"line-color": "#111827",
|
||||
"line-width": 7,
|
||||
"line-opacity": 0.35
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "junctions-halo",
|
||||
type: "circle",
|
||||
source: "junctions",
|
||||
"source-layer": SOURCE_LAYERS.junctions,
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
"circle-color": "#ffffff",
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6],
|
||||
"circle-opacity": 0.82,
|
||||
"circle-stroke-color": "rgba(15,23,42,0.22)",
|
||||
"circle-stroke-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "junctions",
|
||||
type: "circle",
|
||||
source: "junctions",
|
||||
"source-layer": SOURCE_LAYERS.junctions,
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["coalesce", ["get", "demand"], 0],
|
||||
0,
|
||||
"#70c1b3",
|
||||
20,
|
||||
"#247ba0",
|
||||
60,
|
||||
"#f25f5c"
|
||||
],
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8],
|
||||
"circle-opacity": 0.86
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "junctions-hover",
|
||||
type: "circle",
|
||||
source: "junctions",
|
||||
"source-layer": SOURCE_LAYERS.junctions,
|
||||
minzoom: 13,
|
||||
filter: ["==", ["get", "id"], ""],
|
||||
paint: {
|
||||
"circle-color": "#111827",
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10],
|
||||
"circle-opacity": 0.2,
|
||||
"circle-stroke-color": "#111827",
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
||||
import type { MapLegendItem } from "@/features/map/core/components/legend";
|
||||
|
||||
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
|
||||
pipes: ["pipes-casing", "pipes-flow", "pipes-risk-glow", "pipes-hover"],
|
||||
junctions: ["junctions-halo", "junctions", "junctions-hover"],
|
||||
simulation: [
|
||||
"simulation-impact-fill",
|
||||
"simulation-impact-outline",
|
||||
"simulation-burst-halo",
|
||||
"simulation-burst-point",
|
||||
"simulation-pipe-label",
|
||||
"simulation-impact-label",
|
||||
"simulation-district-label",
|
||||
"simulation-burst-label"
|
||||
]
|
||||
};
|
||||
|
||||
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
||||
pipes: true,
|
||||
junctions: true,
|
||||
simulation: false
|
||||
};
|
||||
|
||||
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
||||
{ id: "major-pipe", label: "DN600 及以上管线", color: "#0477bf", shape: "line" },
|
||||
{ id: "medium-pipe", label: "DN300-DN600 管线", color: "#0aa6a6", shape: "line" },
|
||||
{ id: "minor-pipe", label: "DN300 以下管线", color: "#3dbf7f", shape: "line" },
|
||||
{ id: "junction-demand", label: "节点需水强度", color: "#f25f5c", shape: "dot" },
|
||||
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
|
||||
];
|
||||
|
||||
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
||||
|
||||
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
||||
{
|
||||
id: "mapbox-light",
|
||||
label: "浅色",
|
||||
description: "道路底图",
|
||||
swatchClassName: "bg-gradient-to-br from-slate-50 via-slate-100 to-blue-100"
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite",
|
||||
label: "影像",
|
||||
description: "卫星街道",
|
||||
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
|
||||
},
|
||||
{
|
||||
id: "none",
|
||||
label: "无底图",
|
||||
description: "仅业务图层",
|
||||
swatchClassName: "bg-slate-100"
|
||||
}
|
||||
];
|
||||
|
||||
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
|
||||
return [
|
||||
{
|
||||
id: "pipes",
|
||||
label: "管线",
|
||||
description: "主干、支线与风险高亮",
|
||||
visible: layerVisibility.pipes
|
||||
},
|
||||
{
|
||||
id: "junctions",
|
||||
label: "节点",
|
||||
description: "用水节点与悬停反馈",
|
||||
visible: layerVisibility.junctions
|
||||
},
|
||||
{
|
||||
id: "simulation",
|
||||
label: "模拟结果",
|
||||
description: "影响范围与事故标注",
|
||||
visible: layerVisibility.simulation
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
FilterSpecification,
|
||||
GeoJSONSource,
|
||||
GeoJSONSourceSpecification,
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
|
||||
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
|
||||
|
||||
export type MeasurementFeatureCollection = FeatureCollection<Geometry, { kind: "vertex" | "line" | "polygon" }>;
|
||||
|
||||
export const emptyMeasurementFeatureCollection: MeasurementFeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
};
|
||||
|
||||
export const measurementSource = {
|
||||
type: "geojson",
|
||||
data: emptyMeasurementFeatureCollection
|
||||
} satisfies GeoJSONSourceSpecification;
|
||||
|
||||
export const measurementLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
|
||||
type: "line",
|
||||
source: "pipes",
|
||||
"source-layer": SOURCE_LAYERS.pipes,
|
||||
minzoom: 9,
|
||||
filter: ["in", ["get", "id"], ["literal", []]],
|
||||
paint: {
|
||||
"line-color": "#2563eb",
|
||||
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
|
||||
"line-opacity": 0.68
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-polygon-fill",
|
||||
type: "fill",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["==", ["get", "kind"], "polygon"],
|
||||
paint: {
|
||||
"fill-color": "#2563eb",
|
||||
"fill-opacity": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-line",
|
||||
type: "line",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
|
||||
paint: {
|
||||
"line-color": "#2563eb",
|
||||
"line-width": 2.5,
|
||||
"line-dasharray": [2, 1.5],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "workbench-measurement-vertex",
|
||||
type: "circle",
|
||||
source: MEASUREMENT_SOURCE_ID,
|
||||
filter: ["==", ["get", "kind"], "vertex"],
|
||||
paint: {
|
||||
"circle-color": "#2563eb",
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1.5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureMeasurementLayers(map: {
|
||||
getSource: (id: string) => unknown;
|
||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||
getLayer: (id: string) => unknown;
|
||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
|
||||
}) {
|
||||
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
|
||||
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
|
||||
}
|
||||
|
||||
measurementLayers.forEach((layer) => {
|
||||
if (!map.getLayer(layer.id)) {
|
||||
map.addLayer(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setMeasurementSourceData(
|
||||
map: { getSource: (id: string) => unknown },
|
||||
data: MeasurementFeatureCollection
|
||||
) {
|
||||
const source = map.getSource(MEASUREMENT_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
source?.setData(data);
|
||||
}
|
||||
|
||||
export function setSelectedMeasurementPipeIds(
|
||||
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
|
||||
pipeIds: string[]
|
||||
) {
|
||||
if (!map.getLayer(MEASUREMENT_SELECTED_PIPES_LAYER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setFilter(MEASUREMENT_SELECTED_PIPES_LAYER_ID, ["in", ["get", "id"], ["literal", pipeIds]]);
|
||||
}
|
||||
|
||||
export function createMeasurementFeatureCollection(
|
||||
coordinates: [number, number][],
|
||||
mode: "distance" | "area" | "segment"
|
||||
): MeasurementFeatureCollection {
|
||||
const features: MeasurementFeatureCollection["features"] = coordinates.map((coordinate, index) => ({
|
||||
type: "Feature",
|
||||
id: `measurement-vertex-${index}`,
|
||||
properties: { kind: "vertex" },
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: coordinate
|
||||
} satisfies Point
|
||||
}));
|
||||
|
||||
if (coordinates.length >= 2) {
|
||||
features.push({
|
||||
type: "Feature",
|
||||
id: "measurement-line",
|
||||
properties: { kind: "line" },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates
|
||||
} satisfies LineString
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "area" && coordinates.length >= 3) {
|
||||
features.push({
|
||||
type: "Feature",
|
||||
id: "measurement-polygon",
|
||||
properties: { kind: "polygon" },
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [[...coordinates, coordinates[0]]]
|
||||
} satisfies Polygon
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { simulationLayerIds } from "./annotation-layers";
|
||||
|
||||
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
|
||||
const visibility = visible ? "visible" : "none";
|
||||
|
||||
simulationLayerIds.forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
bbox3857: [
|
||||
13508802,
|
||||
3608164,
|
||||
13555651,
|
||||
3633686
|
||||
]
|
||||
} as const;
|
||||
|
||||
export const SOURCE_LAYERS = {
|
||||
pipes: "geo_pipes_mat",
|
||||
junctions: "geo_junctions_mat"
|
||||
} as const;
|
||||
|
||||
export function createWaterNetworkSources() {
|
||||
return {
|
||||
pipes: {
|
||||
type: "vector" as const,
|
||||
tiles: [
|
||||
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_pipes_mat/line/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
|
||||
],
|
||||
minzoom: 0,
|
||||
maxzoom: 24
|
||||
},
|
||||
junctions: {
|
||||
type: "vector" as const,
|
||||
tiles: [
|
||||
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_junctions_mat/generic/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
|
||||
],
|
||||
minzoom: 0,
|
||||
maxzoom: 24
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
const sources: StyleSpecification["sources"] = {};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "background",
|
||||
type: "background",
|
||||
paint: {
|
||||
"background-color": "#eef3f7"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (mapboxToken) {
|
||||
sources["mapbox-light"] = {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
|
||||
],
|
||||
tileSize: 512,
|
||||
attribution:
|
||||
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
};
|
||||
sources["mapbox-satellite"] = {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
|
||||
],
|
||||
tileSize: 512,
|
||||
attribution:
|
||||
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
};
|
||||
|
||||
layers.push({
|
||||
id: "mapbox-light",
|
||||
type: "raster",
|
||||
source: "mapbox-light",
|
||||
paint: {
|
||||
"raster-opacity": 0.82,
|
||||
"raster-saturation": -0.22,
|
||||
"raster-contrast": 0.04
|
||||
}
|
||||
});
|
||||
layers.push({
|
||||
id: "mapbox-satellite",
|
||||
type: "raster",
|
||||
source: "mapbox-satellite",
|
||||
layout: {
|
||||
visibility: "none"
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 0.86,
|
||||
"raster-saturation": -0.16,
|
||||
"raster-contrast": 0.02
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources,
|
||||
layers
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
|
||||
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
|
||||
|
||||
export const waterNetworkToolbarItems: MapToolbarItem[] = [
|
||||
{
|
||||
id: "layers",
|
||||
label: "图层",
|
||||
description: "管理地图图层",
|
||||
icon: Layers3
|
||||
},
|
||||
{
|
||||
id: "measure",
|
||||
label: "测量",
|
||||
description: "距离与范围测量",
|
||||
icon: Ruler
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
label: "分析",
|
||||
description: "查看地图分析图例",
|
||||
icon: BarChart3
|
||||
},
|
||||
{
|
||||
id: "annotation",
|
||||
label: "标注",
|
||||
description: "管理地图标注",
|
||||
icon: MapPinned
|
||||
},
|
||||
{
|
||||
id: "more",
|
||||
label: "更多",
|
||||
description: "更多地图操作",
|
||||
icon: MoreHorizontal
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
|
||||
export type DetailFeature = {
|
||||
id: string;
|
||||
layer: "pipes" | "junctions";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WorkbenchMode = "simulation" | "feature-detail";
|
||||
|
||||
export type WorkbenchScenario = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "draft" | "active" | "review";
|
||||
};
|
||||
|
||||
export type WorkbenchAlert = {
|
||||
id: string;
|
||||
description: string;
|
||||
time: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type WorkbenchUser = {
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
|
||||
|
||||
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
|
||||
|
||||
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
|
||||
|
||||
export type ScheduledConditionSolutionOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
scenario: string;
|
||||
action: string;
|
||||
tradeoff: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionAnalysisInsight = {
|
||||
summary: string;
|
||||
notes: string[];
|
||||
escalationCriteria?: string[];
|
||||
solutionOptions?: ScheduledConditionSolutionOption[];
|
||||
};
|
||||
|
||||
export type ScheduledConditionKpi = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
baseline?: string;
|
||||
threshold?: string;
|
||||
status: ScheduledConditionRiskLevel;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionReport = {
|
||||
title: string;
|
||||
conclusion: string;
|
||||
sections: {
|
||||
title: string;
|
||||
items: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type ScheduledConditionBase = {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: ScheduledConditionStatus;
|
||||
riskLevel: ScheduledConditionRiskLevel;
|
||||
updatedAt: number;
|
||||
detail?: string;
|
||||
recommendation?: string;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type ScheduledConditionRecord = ScheduledConditionBase & {
|
||||
kind: "condition";
|
||||
taskId: ScheduledConditionTaskId;
|
||||
sessionId: string;
|
||||
evidence?: string[];
|
||||
modelName?: string;
|
||||
analysisInsight?: ScheduledConditionAnalysisInsight;
|
||||
kpis?: ScheduledConditionKpi[];
|
||||
report?: ScheduledConditionReport;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
|
||||
kind: "work_order";
|
||||
code: string;
|
||||
source: string;
|
||||
location: string;
|
||||
dispatcher: string;
|
||||
assignee: string;
|
||||
priority: "normal" | "urgent";
|
||||
replyWindowMinutes: number;
|
||||
stages: ScheduledWorkOrderStage[];
|
||||
replyRequirements: string[];
|
||||
evidence?: never;
|
||||
modelName?: never;
|
||||
sessionId?: never;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderStageId = "dispatch" | "execution" | "reply";
|
||||
|
||||
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending";
|
||||
|
||||
export type ScheduledWorkOrderStage = {
|
||||
id: ScheduledWorkOrderStageId;
|
||||
title: string;
|
||||
status: ScheduledWorkOrderStageStatus;
|
||||
owner: string;
|
||||
timeLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
|
||||
|
||||
export type MapAnnotation = {
|
||||
id: string;
|
||||
label: string;
|
||||
coordinate: [number, number];
|
||||
kind: "burst" | "pipe-label" | "district" | "tooltip";
|
||||
};
|
||||
|
||||
export type WorkbenchMap = MapLibreMap;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { formatValue } from "./format-value";
|
||||
|
||||
export type LocalizedFeatureProperty = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PROPERTY_LABELS: Record<string, string> = {
|
||||
id: "编号",
|
||||
gid: "要素编号",
|
||||
name: "名称",
|
||||
code: "编码",
|
||||
type: "类型",
|
||||
node1: "起点节点",
|
||||
node2: "终点节点",
|
||||
from_node: "起点节点",
|
||||
to_node: "终点节点",
|
||||
diameter: "管径",
|
||||
dn: "管径",
|
||||
length: "长度",
|
||||
roughness: "粗糙系数",
|
||||
status: "状态",
|
||||
material: "材质",
|
||||
elevation: "高程",
|
||||
demand: "需水量",
|
||||
pressure: "压力",
|
||||
flow: "流量",
|
||||
velocity: "流速",
|
||||
zone: "分区",
|
||||
district: "片区",
|
||||
source: "来源",
|
||||
updated_at: "更新时间"
|
||||
};
|
||||
|
||||
const PROPERTY_ORDER: Record<string, number> = {
|
||||
id: 10,
|
||||
gid: 11,
|
||||
name: 12,
|
||||
code: 13,
|
||||
diameter: 20,
|
||||
dn: 20,
|
||||
length: 21,
|
||||
roughness: 22,
|
||||
material: 23,
|
||||
node1: 24,
|
||||
from_node: 24,
|
||||
node2: 25,
|
||||
to_node: 25,
|
||||
elevation: 30,
|
||||
demand: 31,
|
||||
pressure: 32,
|
||||
flow: 33,
|
||||
velocity: 34,
|
||||
status: 90
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: "运行中",
|
||||
inactive: "停用",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
open: "开启",
|
||||
closed: "关闭",
|
||||
normal: "正常",
|
||||
warning: "告警",
|
||||
critical: "严重"
|
||||
};
|
||||
|
||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||
return Object.entries(properties).map(([key, value]) => ({
|
||||
key,
|
||||
label: getFeaturePropertyLabel(key),
|
||||
value: formatFeaturePropertyValue(key, value)
|
||||
})).sort((first, second) => getPropertyOrder(first.key) - getPropertyOrder(second.key));
|
||||
}
|
||||
|
||||
export function getFeaturePropertyLabel(key: string) {
|
||||
const normalizedKey = normalizePropertyKey(key);
|
||||
return PROPERTY_LABELS[normalizedKey] ?? key;
|
||||
}
|
||||
|
||||
export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
const normalizedKey = normalizePropertyKey(key);
|
||||
|
||||
if (isStatusKey(key) && typeof value === "string") {
|
||||
return STATUS_LABELS[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
||||
return `DN${formatValue(value)}`;
|
||||
}
|
||||
|
||||
if ((normalizedKey === "length" || normalizedKey === "elevation") && value !== null && value !== undefined && value !== "") {
|
||||
return `${formatValue(value)} m`;
|
||||
}
|
||||
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
function getPropertyOrder(key: string) {
|
||||
return PROPERTY_ORDER[normalizePropertyKey(key)] ?? 100;
|
||||
}
|
||||
|
||||
function normalizePropertyKey(key: string) {
|
||||
return key.trim().replaceAll("-", "_").toLowerCase();
|
||||
}
|
||||
|
||||
function isStatusKey(key: string) {
|
||||
return normalizePropertyKey(key) === "status";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function formatValue(value: unknown) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows";
|
||||
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
const statusLabels: Record<ScheduledConditionStatus, string> = {
|
||||
completed: "完成",
|
||||
running: "执行中",
|
||||
warning: "关注",
|
||||
error: "异常",
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export function createConditionConversationPrompt(condition: ScheduledConditionItem) {
|
||||
const workflow =
|
||||
condition.kind === "condition"
|
||||
? getRunningWorkflowDefinition(condition)
|
||||
: null;
|
||||
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
|
||||
const kpis =
|
||||
condition.kind === "condition" && condition.kpis?.length
|
||||
? condition.kpis
|
||||
.map((kpi, index) => {
|
||||
const value = `${kpi.value}${kpi.unit ?? ""}`;
|
||||
const threshold = kpi.threshold ? `,阈值:${kpi.threshold}` : "";
|
||||
return `${index + 1}. ${kpi.label}:${value},状态:${getRiskLabel(kpi.status)}${threshold}`;
|
||||
})
|
||||
.join("\n")
|
||||
: "无";
|
||||
const report =
|
||||
condition.kind === "condition" && condition.report
|
||||
? [
|
||||
condition.report.conclusion,
|
||||
...condition.report.sections.map(
|
||||
(section) => `${section.title}:${section.items.join(";")}`
|
||||
)
|
||||
].join("\n")
|
||||
: null;
|
||||
|
||||
return [
|
||||
"请继续分析这条工况任务,并基于当前上下文给出下一步调度建议。",
|
||||
"",
|
||||
`时间:${formatTime(condition.scheduledAt)}`,
|
||||
"类型:历史工况",
|
||||
workflow ? `任务工作流:${workflow.title}` : null,
|
||||
workflow ? `工作流步骤:${workflow.steps.map((step, index) => `${index + 1}. ${step.title}`).join(";")}` : null,
|
||||
`标题:${condition.title}`,
|
||||
`状态:${statusLabels[condition.status]}`,
|
||||
`风险:${getRiskLabel(condition.riskLevel)}`,
|
||||
`摘要:${condition.summary}`,
|
||||
condition.detail ? `详情:${condition.detail}` : null,
|
||||
`关键证据:\n${evidence}`,
|
||||
`关键指标:\n${kpis}`,
|
||||
report ? `工况报告:\n${report}` : null,
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.sessionId ? `sessionId:${condition.sessionId}` : null,
|
||||
"",
|
||||
"请先说明当前判断,再列出需要人工确认的事项和建议动作。"
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function createAlertQueueConversationPrompt({
|
||||
alerts,
|
||||
conditions
|
||||
}: {
|
||||
alerts: WorkbenchAlert[];
|
||||
conditions: ScheduledConditionItem[];
|
||||
}) {
|
||||
const alertSummaries = alerts.map((alert, index) => {
|
||||
const condition = findConditionForAlert(alert, conditions);
|
||||
const baseSummary = [
|
||||
`${index + 1}. ${alert.title}`,
|
||||
"状态:待复核",
|
||||
`时间:${alert.time}`,
|
||||
`描述:${alert.description}`
|
||||
];
|
||||
|
||||
if (!condition) {
|
||||
return baseSummary.join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
...baseSummary,
|
||||
`关联工况:${condition.title}`,
|
||||
`工况状态:${statusLabels[condition.status]}`,
|
||||
`风险等级:${getRiskLabel(condition.riskLevel)}`,
|
||||
condition.detail ? `工况详情:${condition.detail}` : null,
|
||||
condition.evidence?.length ? `关键证据:\n${condition.evidence.map((item, evidenceIndex) => `${evidenceIndex + 1}. ${item}`).join("\n")}` : null,
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.kind === "condition" && condition.analysisInsight?.solutionOptions?.length
|
||||
? `已有处置方案:\n${condition.analysisInsight.solutionOptions.map((option, optionIndex) => `${optionIndex + 1}. ${option.title}:${option.action};代价:${option.tradeoff}`).join("\n")}`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
return [
|
||||
"请作为排水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"",
|
||||
"目标:",
|
||||
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
|
||||
"2. 列出支撑判断的关键证据和不确定性。",
|
||||
"3. 给出处置方案,包含建议动作、适用条件、影响范围和需要人工确认的事项。",
|
||||
"4. 不要自动执行阀门、泵站或工单动作,先给调度员确认。",
|
||||
"",
|
||||
`待复核工况数量:${alerts.length}`,
|
||||
"",
|
||||
"待复核工况上下文:",
|
||||
alertSummaries.join("\n\n"),
|
||||
"",
|
||||
"请先给出总体判断,再按工况列出处置建议,最后列出需要补充核查的数据。"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledConditionItem[]) {
|
||||
if (!alert.id.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conditionId = alert.id.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
|
||||
return conditions.find((condition) => condition.id === conditionId) ?? null;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "需重点复核";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "关注";
|
||||
}
|
||||
|
||||
return "正常";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user