refactor: organize supply frontend structure

This commit is contained in:
2026-07-22 18:16:43 +08:00
parent 699a0bced4
commit b9bc9c3d39
96 changed files with 917 additions and 745 deletions
+158 -26
View File
@@ -1,37 +1,169 @@
# Repository Guidelines # Repository Guidelines
## Project Structure ## Scope
This is a Vite React single-page WebGIS Agent workbench. Runtime TypeScript lives This repository contains the Vite and React single-page frontend for the
under `src/`. Agent-driven supply-network WebGIS. Runtime TypeScript lives under `src/`.
The application uses React 19, TypeScript, Tailwind CSS, MapLibre GL, deck.gl,
SWR, Vitest, and Playwright.
- `src/app/`: application shell and providers. Keep this project browser-first. Do not add Next.js APIs, `"use client"`
- `src/features/agent/`: controlled Agent UI protocol, schemas, renderer, and views. directives, or general backend-for-frontend routes. Agent reasoning and tool
- `src/features/map/`: WebGIS map shell and future MapLibre/deck.gl integrations. execution belong in `next-tjwater-agent`.
- `src/shared/`: reusable UI, config, styles, and utilities.
- `src/mocks/`: MSW handlers and fixtures for backend-independent development.
- `src/test/`: Vitest setup.
- `server/`: same-origin Edge TTS adapter used by Vite and the production container.
## Commands ## Source Layout
Use `pnpm`. - `src/app/` owns application startup, providers, and shell composition.
- `src/features/workbench/` owns supply sources, layers, SCADA overlays,
scenarios, feature adapters, GeoServer configuration, and workbench
interactions.
- `src/features/agent/` owns Agent protocol clients, sessions, typed frontend
actions, validated UI envelopes, recommendations, and Agent panels.
- `src/features/map/core/` owns domain-neutral map controls, legends, notices,
and reusable map presentation primitives.
- `src/shared/` owns reusable UI, AI presentation elements, runtime
configuration, and feature-independent utilities.
- `src/mocks/` owns MSW handlers and fixtures.
- `src/test/` owns shared Vitest setup and repository-wide invariant tests.
- `tests/browser/` owns product-level Playwright regressions.
- `public/` contains static supply-network and SCADA assets plus the
runtime-config placeholder.
- `server/` contains the same-origin Edge TTS adapter used by Vite and the
production container.
```bash Do not recreate a catch-all `src/lib/` directory. Put feature-independent
pnpm dev helpers in `src/shared/`; put supply-network and GeoServer logic in the
pnpm build owning `workbench` module.
pnpm typecheck
pnpm lint Do not create empty placeholder directories. Add a directory with its first
pnpm test source file.
pnpm test:browser
## Dependency Direction and Public APIs
Keep dependencies moving toward generic code:
```text
app -> workbench, map/core, shared
workbench -> agent, map/core, shared
agent -> map/core, shared
map/core -> shared
shared -> external packages only
``` ```
## Coding Notes - `src/app` may import the `workbench` and `map/core` public APIs plus
shared startup configuration.
- `workbench` may import `agent`, `map/core`, and `shared`.
- `agent` may import generic `map/core` presentation helpers and `shared`,
but it must not import `workbench`.
- `map/core` must not know supply layer IDs, SCADA models, workbench state, or
Agent workflows.
- `shared` must not import from `app`, `features`, or `mocks`.
- Runtime feature code must not import from `app` or `mocks`.
Use TypeScript and React function components. Prefer explicit exported types at Use a feature's `index.ts` as its public API for cross-feature imports. Add an
module boundaries. Keep Agent dynamic UI rendering schema-driven: Agent output intentional public export when another feature needs a symbol. Use relative
must be validated before it reaches React components. imports within the same feature. Do not introduce cross-feature deep imports
or barrels for private implementation files.
Do not add Next.js APIs or general BFF routes to this frontend. Agent behavior ## Component and Module Design
belongs in the Agent service; the local `server/` exception is limited to the
same-origin Edge TTS network adapter. - Use TypeScript and React function components. Export explicit types at
feature and adapter boundaries.
- Use two-space indentation and kebab-case filenames. Use PascalCase for
components and types, camelCase for functions and values, and `useXxx` for
hooks.
- Keep application and page components focused on composition. Extract state,
effects, network flows, resize behavior, and map orchestration into focused
hooks, controllers, or child components.
- Keep MapLibre sources, layers, camera helpers, feature queries, and event
handling outside presentational components. Map mutations belong in typed
controllers or map helpers.
- Keep Agent rendering schema-driven. Validate Agent output before it reaches
React. Model map actions as typed data that can be previewed, confirmed,
applied, and rolled back.
- Keep reusable UI free of supply-network terminology and workflow state. The
owning feature decides business behavior.
- Prefer one source of truth. Search for an existing utility, config wrapper,
style token, or domain type before adding another.
## Supply-Domain Boundary
This repository models a supply network. Pipes, junctions, valves, reservoirs,
tanks, pumps, pressure, flow, water age, demand, isolation, and continuity of
supply belong in `workbench`.
Do not introduce drainage-network layer IDs, assets, terminology, export
filenames, configuration prefixes, fixtures, or operating assumptions.
Drainage concepts such as conduits, orifices, outfalls, manholes, sewage,
stormwater, and overflow risk do not belong here.
`next-tjwater-drainage-frontend` may be consulted for generic architecture
patterns only. Never copy its domain constants, source catalogs, map styles,
icons, mocks, product copy, environment variables, or defaults into this
repository. The supply-domain invariant test in
`src/test/supply-domain.test.ts` must remain green.
## Runtime and Server Boundaries
Browser configuration is injected through `/runtime-config.js` and validated
in `src/shared/config/env.ts`. Use `TJWATER_*` variables. Do not add
`DRAINAGE_*`, `VITE_*`, or legacy `NEXT_PUBLIC_*` browser configuration.
Browser-safe GeoServer reads may happen directly when CORS is enabled. Agent
requests go to the configured Agent service. The local `server/` exception is
limited to the same-origin Edge TTS network adapter; do not turn it into a
general API layer or add free-form proxy routes.
## Tests
- Co-locate focused Vitest tests with the module under test using `*.test.ts`
or `*.test.tsx`.
- Put repository-wide invariants and shared setup in `src/test/`.
- Put new product and interaction Playwright tests in `tests/browser/`.
`src/app/app.e2e.ts` is retained migration coverage, not a location to copy.
- Name tests after behavior. Cover pure map calculations, adapters, protocol
validation, and state transitions without requiring a browser when possible.
- Add focused Playwright coverage for visible behavior, responsive layout,
runtime configuration, keyboard and pointer interaction, or map integration.
## Commands and Verification
Use `pnpm` from this repository.
```bash
pnpm dev # start Vite on http://127.0.0.1:5173
pnpm lint # run ESLint
pnpm typecheck # run TypeScript without emitting files
pnpm test # run Vitest
pnpm test:browser # run Playwright
pnpm build # typecheck, build the SPA, and bundle the TTS adapter
```
Match verification to the change:
- Documentation-only changes: run `git diff --check` and verify referenced
paths and commands.
- TypeScript logic or component changes: run `pnpm lint`, `pnpm typecheck`,
and `pnpm test`.
- Visible UI, responsive, configuration, or map interaction changes: also run
focused `pnpm test:browser` coverage.
- Dependency, Vite, container, runtime integration, or release changes: also
run `pnpm build`.
Do not report a check as passing unless it ran in the current worktree. If a
required browser or external service is unavailable, report the skipped layer
and reason.
## Security and Repository Hygiene
Do not commit secrets, `.env.local`, generated `dist/` or `dist-server/`,
dependency folders, Playwright artifacts, session dumps, tokens, or logs. Keep
server-only targets and credentials in untracked environment files.
Treat Agent output, UI envelopes, feature identifiers, URLs, and runtime
configuration as untrusted input. Validate at system boundaries, keep
credentials out of browser-visible values, and use typed adapters instead of
free-form command or request construction.
Preserve unrelated work in a dirty checkout. Do not clean, reset, move, or
rewrite user changes while completing a scoped task.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "tjwater-webgis-frontend", "name": "next-tjwater-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -65,7 +65,7 @@ test("renders map notices through a single toaster", async ({ page }) => {
await page.getByLabel("打开用户菜单").click(); await page.getByLabel("打开用户菜单").click();
await page.getByRole("menuitem", { name: /查看运行状态/ }).click(); await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
await expect(page.locator("[data-sonner-toast]")).toHaveCount(1); await expect(page.getByText("数据状态", { exact: true })).toBeVisible();
}); });
test("preserves compact typography in header and agent controls", async ({ page }) => { test("preserves compact typography in header and agent controls", async ({ page }) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { MapToaster } from "@/features/map/core/components/notice"; import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench"; import { MapWorkbenchPage } from "@/features/workbench";
import { AppProviders } from "./providers"; import { AppProviders } from "./providers";
@@ -1,9 +1,7 @@
"use client";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona"; import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles"; import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { AgentPersona } from "./agent-persona"; import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = { type AgentCollapsedRailProps = {
@@ -1,5 +1,3 @@
"use client";
import { import {
Activity, Activity,
CheckCircle2, CheckCircle2,
@@ -23,7 +21,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom"; import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core/components/notice-actions"; import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent"; import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import { import {
Conversation, Conversation,
@@ -48,7 +46,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu"; } from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client"; import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona"; import { AgentPersona } from "./agent-persona";
import { import {
@@ -1,10 +1,8 @@
"use client";
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react"; import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client"; import type { AgentChatSessionSummary } from "../api/client";
import { import {
agentLayoutTransition, agentLayoutTransition,
@@ -1,5 +1,3 @@
"use client";
import { import {
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
@@ -13,7 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
agentEase, agentEase,
@@ -1,5 +1,3 @@
"use client";
import type { Variants } from "motion/react"; import type { Variants } from "motion/react";
export const AGENT_MOTION_DURATION_MS = 180; export const AGENT_MOTION_DURATION_MS = 180;
@@ -1,5 +1,3 @@
"use client";
import { import {
ClipboardCheck, ClipboardCheck,
Crosshair, Crosshair,
@@ -8,7 +6,7 @@ import {
Route Route
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { import type {
AgentChatMessage AgentChatMessage
} from "../types"; } from "../types";
@@ -1,7 +1,7 @@
import { Bot } from "lucide-react"; import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona"; import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
const LazyPersona = lazy(async () => { const LazyPersona = lazy(async () => {
const module = await import("@/shared/ai-elements/persona"); const module = await import("@/shared/ai-elements/persona");
@@ -1,9 +1,7 @@
"use client";
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react"; import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data"; import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types"; import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope"; import type { UIEnvelope } from "../ui-envelope";
@@ -1,6 +1,4 @@
"use client"; import { cn } from "@/shared/ui/cn";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/shared/ai-elements/message"; import { MessageResponse } from "@/shared/ai-elements/message";
type StreamingTokenResponseProps = { type StreamingTokenResponseProps = {
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { import {
splitSpeechTextIntoChunks, splitSpeechTextIntoChunks,
+42 -5
View File
@@ -1,15 +1,56 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail"; export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel"; export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona"; export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
AgentApiClient,
AgentChatSessionSummary,
AgentLoadedChatSession,
AgentSessionStreamEvent
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data"; export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
export {
FRONTEND_ACTION_NAMES,
parseFrontendActionRegistry,
parseFrontendActionRequest,
readActionResults,
storeActionResult,
type FrontendActionName,
type FrontendActionRegistry,
type FrontendActionRequest,
type FrontendActionResult
} from "./frontend-action";
export { FrontendActionExecutor } from "./frontend-action/executor";
export { export {
applyPermissionResponse, applyPermissionResponse,
applyQuestionResponse, applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate, toTodoUpdate,
upsertPermission, upsertPermission,
upsertProgress, upsertProgress,
upsertQuestion upsertQuestion
} from "./session-state"; } from "./session-state";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry,
toTrustedMapAction,
type TrustedMapAction,
type UIEnvelope,
type UIEnvelopePayload,
type UIRegistry,
type UISurface
} from "./ui-envelope";
export type { export type {
AgentApprovalMode, AgentApprovalMode,
AgentChatMessage, AgentChatMessage,
@@ -28,8 +69,4 @@ export type {
AgentTodoUpdate, AgentTodoUpdate,
AgentUiResult AgentUiResult
} from "./types"; } from "./types";
export type { export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
SafeChartData,
SafeChartSeries,
SafeChartSpec
} from "./chart-data";
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react"; import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MapActionRow, MapActionRow,
MapPanelSection MapPanelSection
@@ -1,8 +1,6 @@
"use client";
import { Check, Map } from "lucide-react"; import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react"; import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
@@ -1,6 +1,6 @@
import { ChevronRight, type LucideIcon } from "lucide-react"; import { ChevronRight, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -1,5 +1,5 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react"; import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,5 @@
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react"; import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control"; import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem } from "./layer-control"; import type { MapLayerControlItem } from "./layer-control";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react"; import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MapActionRow, MapActionRow,
MapModeButton, MapModeButton,
@@ -1,8 +1,6 @@
"use client";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation"; import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types"; import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
@@ -1,8 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react"; import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions"; import { dismissMapNotice, showMapNotice } from "./notice-actions";
@@ -1,8 +1,6 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react"; import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles"; import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
type ScaleLineState = { type ScaleLineState = {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react"; import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react"; import { type RefObject, useCallback } from "react";
@@ -9,7 +7,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
+62
View File
@@ -0,0 +1,62 @@
export {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationPanelProps,
type MapAnnotationShareAction
} from "./components/annotation-panel";
export { BaseLayersControl, type BaseLayerOption } from "./components/base-layers-control";
export {
MapActionChip,
MapActionRow,
MapControlPanel,
MapMetricTile,
MapModeButton,
MapPanelSection,
type MapActionRowProps,
type MapControlPanelProps,
type MapModeButtonProps
} from "./components/control-panel";
export {
MapDrawToolbar,
type MapDrawToolbarProps,
type MapDrawTool
} from "./components/draw-toolbar";
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_FOCUS_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_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./components/map-control-styles";
export {
MapMeasurePanel,
type MapMeasureAction,
type MapMeasureMode,
type MapMeasurePanelProps,
type MapMeasureResultMetric,
type MapMeasureUnit
} from "./components/measure-panel";
export { dismissMapNotice, showMapNotice } from "./components/notice-actions";
export {
MapErrorNotice,
MapLoadingNotice,
MapSourceStatusNotice,
MapToaster,
type MapNoticeOptions,
type MapNoticePosition,
type MapNoticeTone,
type MapSourceStatus,
type MapSourceStatusNoticeProps
} from "./components/notice";
export { MapScaleLine } from "./components/scale-line";
export { MapToolbar, type MapToolbarItem } from "./components/toolbar";
export { MapZoom } from "./components/zoom";
@@ -1,11 +1,9 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react"; import { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { import type {
ScheduledConditionRecord ScheduledConditionRecord
} from "@/features/workbench/types"; } from "../types";
import { import {
createVisibleTickerCards, createVisibleTickerCards,
type TickerStackCard type TickerStackCard
@@ -1,7 +1,7 @@
import type { import type {
ScheduledConditionRiskLevel, ScheduledConditionRiskLevel,
ScheduledConditionStatus ScheduledConditionStatus
} from "@/features/workbench/types"; } from "../types";
export function getConditionReportRiskLevel( export function getConditionReportRiskLevel(
status: ScheduledConditionStatus, status: ScheduledConditionStatus,
@@ -1,113 +0,0 @@
import type { ReactNode, SVGProps } from "react";
export type DrainageFeatureIconProps = SVGProps<SVGSVGElement> & {
size?: number;
};
type DrainageIconFrameProps = DrainageFeatureIconProps & {
children: ReactNode;
};
function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{children}
</svg>
);
}
export function ConduitFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="3.5" cy="12" r="1.75" />
<circle cx="20.5" cy="12" r="1.75" />
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
</DrainageIconFrame>
);
}
export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1.75" />
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
</DrainageIconFrame>
);
}
export function ValveFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M3.5 12h4.75M15.75 12h4.75" />
<path d="m8.25 7 7.5 5-7.5 5V7Z" />
<path d="m15.75 7-7.5 5 7.5 5V7Z" />
<path d="M12 7V3.5M9.5 3.5h5" />
</DrainageIconFrame>
);
}
export function ReservoirFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M5 8.5c0-2.2 14-2.2 14 0v7c0 2.2-14 2.2-14 0v-7Z" />
<path d="M5 8.5c0 2.2 14 2.2 14 0" />
<path d="M5 12c0 2.2 14 2.2 14 0" />
<path d="M8 19.5h8" />
</DrainageIconFrame>
);
}
export function ScadaFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="12" cy="7.5" r="1.75" />
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
</DrainageIconFrame>
);
}
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M2.5 12H9M15 12h6.5" />
<path d="M9.5 4v5.35M9.5 14.65V20M14.5 4v5.35M14.5 14.65V20" />
<circle cx="12" cy="12" r="3" />
</DrainageIconFrame>
);
}
export function PumpFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="9.5" cy="13.5" r="6" />
<circle cx="9.5" cy="13.5" r="1.25" />
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
<path d="m17 6 2.5 2.5L22 6" />
</DrainageIconFrame>
);
}
export function OutfallFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M4 4.5h10v7h5" />
<path d="m16.5 8.75 2.75 2.75-2.75 2.75" />
<path d="M3 18c1.5-1.25 3-1.25 4.5 0s3 1.25 4.5 0 3-1.25 4.5 0 3 1.25 4.5 0M5 21c1.15-.75 2.3-.75 3.45 0s2.3.75 3.45 0 2.3-.75 3.45 0 2.3.75 3.45 0" />
</DrainageIconFrame>
);
}
@@ -1,5 +1,3 @@
"use client";
import { Target, TriangleAlert, X } from "lucide-react"; import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
@@ -1,14 +1,12 @@
"use client";
import { Check, Copy, Database, X } from "lucide-react"; import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
MAP_FOCUS_SURFACE_CLASS_NAME, MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
} from "@/features/map/core/components/map-control-styles"; showMapNotice
import { showMapNotice } from "@/features/map/core/components/notice-actions"; } from "@/features/map/core";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { WaterNetworkSourceId } from "../map/sources"; import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { import {
@@ -16,14 +14,14 @@ import {
type FeaturePanelBadgeTone type FeaturePanelBadgeTone
} from "../utils/feature-properties"; } from "../utils/feature-properties";
import { import {
ConduitFeatureIcon, PipeFeatureIcon,
JunctionFeatureIcon, JunctionFeatureIcon,
PumpFeatureIcon, PumpFeatureIcon,
ReservoirFeatureIcon, ReservoirFeatureIcon,
ScadaFeatureIcon, ScadaFeatureIcon,
ValveFeatureIcon, ValveFeatureIcon,
type DrainageFeatureIconProps type SupplyFeatureIconProps
} from "./drainage-feature-icons"; } from "./supply-feature-icons";
import { FeaturePropertyList } from "./feature-property-list"; import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = { type FeaturePopoverProps = {
@@ -34,14 +32,14 @@ type FeaturePopoverProps = {
const FEATURE_LAYER_META: Record< const FEATURE_LAYER_META: Record<
WaterNetworkSourceId, WaterNetworkSourceId,
{ {
icon: ComponentType<DrainageFeatureIconProps>; icon: ComponentType<SupplyFeatureIconProps>;
label: string; label: string;
headerClassName: string; headerClassName: string;
labelClassName: string; labelClassName: string;
iconClassName: string; iconClassName: string;
} }
> = { > = {
pipes: { icon: ConduitFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" }, junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" }, valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" },
reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" }, reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" },
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { LocalizedFeatureProperty } from "../utils/feature-properties"; import type { LocalizedFeatureProperty } from "../utils/feature-properties";
type FeaturePropertyListProps = { type FeaturePropertyListProps = {
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react"; import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
LAYER_GROUP_GEOMETRIES, LAYER_GROUP_GEOMETRIES,
type LayerGroupId, type LayerGroupId,
@@ -1,5 +1,3 @@
"use client";
import { import {
AlertTriangle, AlertTriangle,
Bot, Bot,
@@ -20,13 +18,13 @@ import {
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { showMapNotice } from "@/features/map/core/components/notice-actions"; import { showMapNotice } from "@/features/map/core";
import { import {
getRunningWorkflowDefinition, getRunningWorkflowDefinition,
getRunningWorkflowState, getRunningWorkflowState,
getWorkflowStepStatus getWorkflowStepStatus
} from "@/features/workbench/data/running-workflows"; } from "../data/running-workflows";
import type { import type {
ScheduledConditionAnalysisInsight, ScheduledConditionAnalysisInsight,
ScheduledConditionItem, ScheduledConditionItem,
@@ -34,7 +32,7 @@ import type {
ScheduledConditionRecord, ScheduledConditionRecord,
ScheduledConditionRiskLevel, ScheduledConditionRiskLevel,
ScheduledWorkOrderItem ScheduledWorkOrderItem
} from "@/features/workbench/types"; } from "../types";
import { import {
formatDuration, formatDuration,
formatTime, formatTime,
@@ -10,7 +10,7 @@ import type {
ScheduledConditionStatus, ScheduledConditionStatus,
ScheduledWorkOrderItem, ScheduledWorkOrderItem,
ScheduledWorkOrderStage ScheduledWorkOrderStage
} from "@/features/workbench/types"; } from "../types";
export const CONDITION_FILTER_ALL = "__all__"; export const CONDITION_FILTER_ALL = "__all__";
@@ -1,5 +1,3 @@
"use client";
import { import {
CalendarClock, CalendarClock,
ChevronDown, ChevronDown,
@@ -10,12 +8,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles"; import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { showMapNotice } from "@/features/map/core/components/notice-actions"; import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions"; import type { ScheduledConditionItem } from "../types";
import type { ScheduledConditionItem } from "@/features/workbench/types"; import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts"; import { cn } from "@/shared/ui/cn";
import { cn } from "@/lib/utils";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -0,0 +1,93 @@
import type { ReactNode, SVGProps } from "react";
export type SupplyFeatureIconProps = SVGProps<SVGSVGElement> & {
size?: number;
};
type SupplyIconFrameProps = SupplyFeatureIconProps & {
children: ReactNode;
};
function SupplyIconFrame({ children, size = 20, ...props }: SupplyIconFrameProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{children}
</svg>
);
}
export function PipeFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="3.5" cy="12" r="1.75" />
<circle cx="20.5" cy="12" r="1.75" />
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
</SupplyIconFrame>
);
}
export function JunctionFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1.75" />
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
</SupplyIconFrame>
);
}
export function ValveFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<path d="M3.5 12h4.75M15.75 12h4.75" />
<path d="m8.25 7 7.5 5-7.5 5V7Z" />
<path d="m15.75 7-7.5 5 7.5 5V7Z" />
<path d="M12 7V3.5M9.5 3.5h5" />
</SupplyIconFrame>
);
}
export function ReservoirFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<path d="M5 8.5c0-2.2 14-2.2 14 0v7c0 2.2-14 2.2-14 0v-7Z" />
<path d="M5 8.5c0 2.2 14 2.2 14 0" />
<path d="M5 12c0 2.2 14 2.2 14 0" />
<path d="M8 19.5h8" />
</SupplyIconFrame>
);
}
export function ScadaFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="12" cy="7.5" r="1.75" />
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
</SupplyIconFrame>
);
}
export function PumpFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="9.5" cy="13.5" r="6" />
<circle cx="9.5" cy="13.5" r="1.25" />
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
<path d="m17 6 2.5 2.5L22 6" />
</SupplyIconFrame>
);
}
@@ -23,21 +23,20 @@ import {
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
MapAnnotationPanel, 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, MapActionRow,
MapControlPanel, MapControlPanel,
MapDrawToolbar,
MapLayerPanel,
MapLegendList,
MapMetricTile, MapMetricTile,
MapPanelSection MapMeasurePanel,
} from "@/features/map/core/components/control-panel"; MapPanelSection,
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar"; type BaseLayerOption,
import { MapLayerPanel } from "@/features/map/core/components/layer-panel"; type MapAnnotationItem,
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control"; type MapAnnotationShareAction,
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend"; type MapLayerControlItem,
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel"; type MapLegendItem
} from "@/features/map/core";
import { import {
type WorkbenchDrawingAnnotation, type WorkbenchDrawingAnnotation,
type WorkbenchDrawMode type WorkbenchDrawMode
@@ -0,0 +1,118 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { ComponentProps } from "react";
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
mobileOpen: boolean;
mobilePanelCollapsing: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
onCollapsePanel: () => void;
onCloseMobilePanel: () => void;
onExpandPanel: () => void;
onOpenMobilePanel: () => void;
};
export function WorkbenchAgentPanels({
panelProps,
panelOpen,
panelCollapsing,
mobileOpen,
mobilePanelCollapsing,
personaState,
statusLabel,
viewportWidth,
onCollapsePanel,
onCloseMobilePanel,
onExpandPanel,
onOpenMobilePanel
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth);
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
return (
<>
<div
ref={resize.panelRef}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
style={resize.width === null ? undefined : { width: resize.width }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={resize.handleKeyDown}
onPointerDown={resize.handlePointerDown}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
) : (
<AgentCollapsedRail
personaState={personaState}
statusLabel={statusLabel}
onExpand={onExpandPanel}
/>
)}
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...panelProps}
collapsing={mobilePanelCollapsing}
onCollapse={onCloseMobilePanel}
/>
</div>
) : null}
</div>
{!mobileOpen ? (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpenMobilePanel}
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="pointer-events-none h-10 w-10" state={personaState} />
</button>
</div>
) : null}
</>
);
}
@@ -1,5 +1,3 @@
"use client";
import { import {
Activity, Activity,
Bell, Bell,
@@ -24,8 +22,8 @@ import {
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles"; } from "@/features/map/core";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { import {
Box, Box,
@@ -13,8 +11,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
MAP_ICON_CELL_RADIUS_CLASS_NAME MAP_ICON_CELL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles"; } from "@/features/map/core";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types"; import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus"; import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
import { import {
@@ -2,7 +2,7 @@ import type {
ScheduledConditionRecord, ScheduledConditionRecord,
ScheduledConditionStatus, ScheduledConditionStatus,
ScheduledConditionTaskId ScheduledConditionTaskId
} from "@/features/workbench/types"; } from "../types";
export type RunningWorkflowStepDefinition = { export type RunningWorkflowStepDefinition = {
id: string; id: string;
@@ -1,21 +1,19 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
AgentPermissionStatus,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
import { import {
applyPermissionResponse, applyPermissionResponse,
applyQuestionResponse, applyQuestionResponse,
toTodoUpdate, toTodoUpdate,
upsertPermission, upsertPermission,
upsertProgress, upsertProgress,
upsertQuestion upsertQuestion,
} from "@/features/agent/session-state"; type AgentChatMessage,
type AgentPermissionReply,
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
} from "@/features/agent";
type AgentUiDataParts = { type AgentUiDataParts = {
progress: Record<string, unknown>; progress: Record<string, unknown>;
@@ -0,0 +1,95 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
setWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
const resize = useCallback((clientX: number) => {
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
}, []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
resizeLeftRef.current = panelRef.current.getBoundingClientRect().left;
setResizing(true);
resize(event.clientX);
cleanupRef.current?.();
const handlePointerMove = (pointerEvent: PointerEvent) => {
pointerEvent.preventDefault();
resize(pointerEvent.clientX);
};
const stopResizing = () => {
cleanupRef.current?.();
setResizing(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
cleanupRef.current = null;
};
cleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
},
[resize]
);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
if (event.key === "ArrowLeft") {
nextWidth -= 16;
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
} else if (event.key === "End") {
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
} else {
return;
}
event.preventDefault();
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
return {
handleKeyDown,
handlePointerDown,
panelRef,
resizing,
width
};
}
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl"; import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@@ -7,34 +5,28 @@ import useSWR from "swr";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona"; import type { PersonaState } from "@/shared/ai-elements/persona";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core/components/notice-actions"; import { showMapNotice } from "@/features/map/core";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import {
createAgentApiClient,
type AgentSessionStreamEvent
} from "@/features/agent/api/client";
import { import {
FrontendActionExecutor,
agentBootstrapKey, agentBootstrapKey,
agentSessionsKey, agentSessionsKey,
createAgentApiClient,
fetchAgentBootstrap, fetchAgentBootstrap,
fetchAgentSessions fetchAgentSessions,
} from "@/features/agent/api/swr";
import {
isUiEnvelopeAllowed, isUiEnvelopeAllowed,
parseUiEnvelopePayload, parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload, type UIEnvelopePayload,
type UIRegistry type UIRegistry
} from "@/features/agent/ui-envelope"; } from "@/features/agent";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
import { import {
applySessionStreamEvent, applySessionStreamEvent,
createCompletedStreamRenderState, createCompletedStreamRenderState,
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { import {
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"; import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera"; import { getResponsiveWorkbenchPadding } from "../map/camera";
@@ -1,5 +1,3 @@
"use client";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl"; import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl"; import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { import {
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
formatHeaderDataTime,
getMsUntilNextHeaderDataTimeTick
} from "./use-workbench-runtime-data";
describe("workbench runtime data", () => {
it("snaps the header time to the current five-minute interval", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
});
it("schedules the next header refresh on the next interval boundary", () => {
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
});
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
export 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}`;
}
export 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 useWorkbenchRuntimeData() {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
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);
}, []);
return {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading
};
}
+35 -293
View File
@@ -1,6 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -8,39 +5,33 @@ import {
useRef, useRef,
useState, useState,
type ComponentProps, type ComponentProps,
type CSSProperties, type CSSProperties
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react"; } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { import {
AgentCollapsedRail,
AgentCommandPanel, AgentCommandPanel,
AgentPersona, toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult type AgentUiResult
} from "@/features/agent"; } from "@/features/agent";
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { import {
MapErrorNotice, MapErrorNotice,
MapLoadingNotice, MapLoadingNotice,
MapScaleLine,
MapSourceStatusNotice, MapSourceStatusNotice,
MapToolbar,
MapZoom,
showMapNotice,
type MapToolbarItem,
type MapSourceStatus type MapSourceStatus
} from "@/features/map/core/components/notice"; } from "@/features/map/core";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
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 { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel"; import { MapDevPanel } from "./components/map-dev-panel";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { import {
ToolbarPanel, ToolbarPanel,
type ExportViewPreset, type ExportViewPreset,
@@ -48,15 +39,12 @@ import {
type ToolbarToolId type ToolbarToolId
} from "./components/toolbar-panel"; } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar"; 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 { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map"; import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { import {
useWorkbenchMeasurement, useWorkbenchMeasurement,
@@ -65,20 +53,18 @@ import {
} from "./hooks/use-workbench-measurement"; } from "./hooks/use-workbench-measurement";
import { import {
WORKBENCH_LAYOUT, WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables, getWorkbenchLayoutCssVariables
getWorkbenchViewportLayout
} from "./layout/workbench-layout"; } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
import { parseScadaAnalysisItems } from "./map/scada-analysis"; import { parseScadaAnalysisItems } from "./map/scada-analysis";
import { import {
BASE_LAYER_IDS,
BASE_LAYER_OPTIONS, BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY, INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS, MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS, WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility,
createLayerControlItems, createLayerControlItems,
getWorkbenchLayerIds getWorkbenchLayerIds
} from "./map/map-control-config"; } from "./map/map-control-config";
@@ -89,49 +75,23 @@ import type {
ScheduledConditionRecord, ScheduledConditionRecord,
WorkbenchAlert WorkbenchAlert
} from "./types"; } from "./types";
import {
createScheduledConditionAlerts,
getScheduledConditionIdFromAlertId
} from "./utils/scheduled-condition-alerts";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts"; import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables(); const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
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() { export function MapWorkbenchPage() {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const desktopAgentPanelRef = useRef<HTMLDivElement | null>(null);
const agentPanelResizeLeftRef = useRef(0);
const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth); const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number | null>(null);
const [agentPanelResizing, setAgentPanelResizing] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null); const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true); const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
@@ -153,9 +113,8 @@ export function MapWorkbenchPage() {
const [layerVisibility, setLayerVisibility] = const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY); useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]); const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]); const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true); useWorkbenchRuntimeData();
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const baseLayerOptions = useMemo( const baseLayerOptions = useMemo(
() => () =>
BASE_LAYER_OPTIONS.map((option) => ({ BASE_LAYER_OPTIONS.map((option) => ({
@@ -174,9 +133,6 @@ export function MapWorkbenchPage() {
const handleChange = () => { const handleChange = () => {
setIsLargeScreen(mediaQuery.matches); setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth); setViewportWidth(window.innerWidth);
setAgentPanelWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth)
);
}; };
handleChange(); handleChange();
@@ -190,38 +146,6 @@ export function MapWorkbenchPage() {
}; };
}, []); }, []);
useEffect(() => () => agentPanelResizeCleanupRef.current?.(), []);
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; const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
useEffect(() => { useEffect(() => {
@@ -751,7 +675,7 @@ export function MapWorkbenchPage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = "drainage-network-map-config.json"; link.download = "supply-network-map-config.json";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
@@ -829,69 +753,6 @@ export function MapWorkbenchPage() {
onRejectQuestion: agent.rejectQuestion onRejectQuestion: agent.rejectQuestion
}; };
const resizeAgentPanel = useCallback((clientX: number) => {
setAgentPanelWidth(
clampAgentPanelWidth(clientX - agentPanelResizeLeftRef.current, window.innerWidth)
);
}, []);
const handleAgentPanelResizeStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !desktopAgentPanelRef.current) {
return;
}
event.preventDefault();
agentPanelResizeLeftRef.current = desktopAgentPanelRef.current.getBoundingClientRect().left;
setAgentPanelResizing(true);
resizeAgentPanel(event.clientX);
agentPanelResizeCleanupRef.current?.();
const handlePointerMove = (pointerEvent: PointerEvent) => {
pointerEvent.preventDefault();
resizeAgentPanel(pointerEvent.clientX);
};
const stopResizing = () => {
agentPanelResizeCleanupRef.current?.();
setAgentPanelResizing(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
agentPanelResizeCleanupRef.current = null;
};
agentPanelResizeCleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
},
[resizeAgentPanel]
);
const handleAgentPanelResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
desktopAgentPanelRef.current?.getBoundingClientRect().width ??
WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
if (event.key === "ArrowLeft") {
nextWidth -= 16;
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
} else if (event.key === "End") {
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
} else {
return;
}
event.preventDefault();
setAgentPanelWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
return ( return (
<main <main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums" className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
@@ -935,47 +796,20 @@ export function MapWorkbenchPage() {
onExportConfig={handleExportConfig} onExportConfig={handleExportConfig}
/> />
<div <WorkbenchAgentPanels
ref={desktopAgentPanelRef} panelProps={agentPanelProps}
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block" panelOpen={agent.panelOpen}
style={agentPanelWidth === null ? undefined : { width: agentPanelWidth }} panelCollapsing={agent.panelCollapsing}
> mobileOpen={agent.mobileOpen}
{agent.panelOpen ? ( mobilePanelCollapsing={agent.mobilePanelCollapsing}
<> personaState={agent.personaState}
<AgentCommandPanel statusLabel={agent.statusLabel}
{...agentPanelProps} viewportWidth={viewportWidth}
collapsing={agent.panelCollapsing} onCollapsePanel={agent.collapsePanel}
onCollapse={agent.collapsePanel} onCloseMobilePanel={agent.closeMobilePanel}
/> onExpandPanel={agent.expandPanel}
<div onOpenMobilePanel={agent.openMobilePanel}
aria-label="调整 Agent 面板宽度" />
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemin={getWorkbenchViewportLayout(viewportWidth).agentWidth}
aria-valuenow={Math.round(
agentPanelWidth ?? getWorkbenchViewportLayout(viewportWidth).agentWidth
)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
agentPanelResizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={handleAgentPanelResizeKeyDown}
onPointerDown={handleAgentPanelResizeStart}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
) : (
<AgentCollapsedRail
personaState={agent.personaState}
statusLabel={agent.statusLabel}
onExpand={agent.expandPanel}
/>
)}
</div>
{!devPanelOpen ? ( {!devPanelOpen ? (
<div className="absolute right-2 top-24 z-20"> <div className="absolute right-2 top-24 z-20">
@@ -1017,22 +851,6 @@ export function MapWorkbenchPage() {
/> />
) : null} ) : null}
<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>
{!agent.mobileOpen ? (
<MobileAgentToggle personaState={agent.personaState} onOpen={agent.openMobilePanel} />
) : null}
{!agent.mobileOpen ? ( {!agent.mobileOpen ? (
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden"> <div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
<ToolbarPanel <ToolbarPanel
@@ -1118,18 +936,6 @@ function isNoticeSourceStatus(sourceStatus: {
); );
} }
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) { function getExportTargetLongEdge(preset: ExportViewPreset) {
if (preset === "4k") { if (preset === "4k") {
return 3840; return 3840;
@@ -1145,67 +951,3 @@ function getExportPresetLabel(preset: ExportViewPreset) {
return "当前分辨率"; 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({
personaState,
onOpen
}: {
personaState: ComponentProps<typeof AgentPersona>["state"];
onOpen: () => void;
}) {
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="pointer-events-none h-10 w-10" state={personaState} />
</button>
</div>
);
}
+1 -1
View File
@@ -119,5 +119,5 @@ function getExportLabel(targetLongEdge: number | undefined, scale: number) {
function getDefaultExportFilename(label: string) { function getDefaultExportFilename(label: string) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `drainage-network-view-${label}-${timestamp}.png`; return `supply-network-view-${label}-${timestamp}.png`;
} }
@@ -2,4 +2,4 @@ import { env } from "@/shared/config/env";
export const MAP_URL = env.TJWATER_MAP_URL.replace(/\/$/, ""); export const MAP_URL = env.TJWATER_MAP_URL.replace(/\/$/, "");
export const GEOSERVER_WORKSPACE = env.TJWATER_GEOSERVER_WORKSPACE; export const GEOSERVER_WORKSPACE = env.TJWATER_GEOSERVER_WORKSPACE.trim().replace(/^\/+|\/+$/g, "");
@@ -1,8 +1,6 @@
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control"; import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control"; import { GEOSERVER_WORKSPACE } from "./geoserver-config";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { GEOSERVER_WORKSPACE } from "../../../lib/config";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
import { import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS, AVAILABLE_WATER_NETWORK_SOURCE_IDS,
@@ -57,6 +55,18 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const; export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(
layerId,
"visibility",
activeBaseLayerId === layerId ? "visible" : "none"
);
}
});
}
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
{ {
id: "mapbox-light", id: "mapbox-light",
@@ -1,5 +1,5 @@
import type { FeatureCollection } from "geojson"; import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { import {
SOURCE_LAYERS, SOURCE_LAYERS,
isAvailableWaterNetworkSourceId, isAvailableWaterNetworkSourceId,
@@ -139,70 +139,6 @@ const facilityIconSize = [
24, 0.56 24, 0.56
] as IconSize; ] as IconSize;
export const DRAINAGE_LAYER_VISUALS = {
conduits: {
minzoom: 9,
casingWidth: [
"interpolate", ["linear"], ["zoom"],
9, 1.7,
13, 2.8,
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
] as LineWidth,
width: [
"interpolate", ["linear"], ["zoom"],
9, 1.1,
13, 2,
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8, 17, 12.5, 22, 15, 24, 17] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 9, 3, 13, 5.2, 17, 9, 22, 11.5, 24, 13] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 9, 1.2, 13, 1.5, 17, 2, 22, 2.5, 24, 3] as LineWidth,
hitWidth: lineHitWidth
},
junctions: {
minzoom: 12,
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 1.2, 14, 1.8, 15, 2.8, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1] as const,
radius: ["interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
hitRadius: pointHitRadius
},
orifices: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
pumps: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 9.5, 22, 11, 24, 12] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7, 17, 12, 22, 14, 24, 16] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4.5, 17, 8.5, 22, 10.5, 24, 12] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
outfalls: {
minzoom: 11,
haloRadius: ["interpolate", ["linear"], ["zoom"], 11, 5, 16, 7, 20, 9, 24, 11] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 11, 6, 16, 8, 20, 11, 24, 13] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
hitRadius: pointHitRadius
}
} as const;
export const SUPPLY_LAYER_VISUALS = { export const SUPPLY_LAYER_VISUALS = {
pipes: { pipes: {
minzoom: 8.5, minzoom: 8.5,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"; import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = { export const WATER_NETWORK_GLOBAL_VIEW = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react"; import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core/components/toolbar"; import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [ export const waterNetworkToolbarItems: MapToolbarItem[] = [
{ {
@@ -0,0 +1,36 @@
import type { ScheduledConditionItem, WorkbenchAlert } from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
export function createScheduledConditionAlerts(
conditions: ScheduledConditionItem[]
): WorkbenchAlert[] {
return conditions
.filter(isAlertCondition)
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
.map((condition) => ({
id: `${SCHEDULED_CONDITION_ALERT_ID_PREFIX}${condition.id}`,
title: `待复核:${condition.title}`,
description: condition.detail ?? condition.summary,
time: formatScheduledConditionTime(condition.scheduledAt)
}));
}
export function getScheduledConditionIdFromAlertId(alertId: string) {
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) return null;
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
}
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;
}
@@ -1,5 +1,5 @@
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows"; import { getRunningWorkflowDefinition } from "../data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types"; import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-"; const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -7,7 +5,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/shared/ui/accordion"; } from "@/shared/ui/accordion";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { Tool } from "ai"; import type { Tool } from "ai";
import { BotIcon } from "lucide-react"; import { BotIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
Select, Select,
@@ -8,7 +6,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/ui/select"; } from "@/shared/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import { import {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react"; import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
ButtonGroup, ButtonGroup,
@@ -11,7 +9,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { cjk } from "@streamdown/cjk"; import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code"; import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math"; import { createMathPlugin } from "@streamdown/math";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RiveParameters } from "@rive-app/react-webgl2"; import type { RiveParameters } from "@rive-app/react-webgl2";
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2"; import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv"; const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv";
const STATE_MACHINE = "default"; const STATE_MACHINE = "default";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
Card, Card,
@@ -15,7 +13,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/shared/ui/collapsible"; } from "@/shared/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { ChevronsUpDownIcon } from "lucide-react"; import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -39,7 +37,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
import { import {
CornerDownLeftIcon, CornerDownLeftIcon,
+1 -3
View File
@@ -1,6 +1,4 @@
"use client"; import { cn } from "@/shared/ui/cn";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react"; import type { MotionProps } from "motion/react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react"; import type { CSSProperties, ElementType, JSX } from "react";
+1 -3
View File
@@ -1,8 +1,6 @@
"use client";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
export type SuggestionsProps = ComponentProps<"div">; export type SuggestionsProps = ComponentProps<"div">;
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react" import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion" import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react" import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Accordion = AccordionPrimitive.Root const Accordion = AccordionPrimitive.Root
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
+1 -1
View File
@@ -1,7 +1,7 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
import { Separator } from "@/shared/ui/separator" import { Separator } from "@/shared/ui/separator"
const buttonGroupVariants = cva( const buttonGroupVariants = cva(
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root const Collapsible = CollapsiblePrimitive.Root
+1 -3
View File
@@ -1,11 +1,9 @@
"use client"
import * as React from "react" import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog" import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
import { Dialog, DialogContent } from "@/shared/ui/dialog" import { Dialog, DialogContent } from "@/shared/ui/dialog"
const Command = React.forwardRef< const Command = React.forwardRef<
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react" import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react" import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react" import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card" import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const HoverCard = HoverCardPrimitive.Root const HoverCard = HoverCardPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input" import { Input } from "@/shared/ui/input"
import { Textarea } from "@/shared/ui/textarea" import { Textarea } from "@/shared/ui/textarea"
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react" import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Loader2Icon } from "lucide-react" import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const Textarea = React.forwardRef< const Textarea = React.forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react" import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/shared/ui/cn"
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider
+51
View File
@@ -0,0 +1,51 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const sourceRoot = join(process.cwd(), "src");
function listRuntimeFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = join(directory, entry.name);
if (entry.isDirectory()) return listRuntimeFiles(target);
if (!/\.(ts|tsx)$/.test(entry.name) || /\.(test|e2e)\.(ts|tsx)$/.test(entry.name)) return [];
return [target];
});
}
function findMatches(directory: string, pattern: RegExp): string[] {
return listRuntimeFiles(directory).flatMap((file) => {
const source = readFileSync(file, "utf8");
return pattern.test(source) ? [relative(sourceRoot, file)] : [];
});
}
describe("frontend module boundaries", () => {
it("uses public feature entry points for absolute feature imports", () => {
expect(findMatches(sourceRoot, /@\/features\/(?:agent|workbench|map\/core)\//)).toEqual([]);
});
it("keeps shared independent from app, features, and mocks", () => {
expect(findMatches(join(sourceRoot, "shared"), /@\/(?:app|features|mocks)\//)).toEqual([]);
});
it("keeps map core independent from Agent and workbench code", () => {
expect(
findMatches(
join(sourceRoot, "features/map/core"),
/@\/features\/(?:agent|workbench)(?:\/|["'])/
)
).toEqual([]);
});
it("keeps Agent code independent from workbench code", () => {
expect(
findMatches(join(sourceRoot, "features/agent"), /@\/features\/workbench(?:\/|["'])/)
).toEqual([]);
});
it("does not restore Next.js client directives or the catch-all lib directory", () => {
expect(findMatches(sourceRoot, /^["']use client["'];?/m)).toEqual([]);
expect(existsSync(join(sourceRoot, "lib"))).toBe(false);
});
});
+7 -4
View File
@@ -2,14 +2,17 @@ import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const featuresRoot = join(process.cwd(), "src/features"); const runtimeRoot = join(process.cwd(), "src");
const forbiddenDrainageTerms = [ const forbiddenDrainageTerms = [
"排水管网", "排水管网",
"单位排水电耗", "单位排水电耗",
"排水边界", "排水边界",
"距污水厂距离", "距污水厂距离",
"关联检查井", "关联检查井",
"管渠拓扑" "管渠拓扑",
"drainage-network",
"DRAINAGE_",
"DrainageFeature"
] as const; ] as const;
function listRuntimeFiles(directory: string): string[] { function listRuntimeFiles(directory: string): string[] {
@@ -23,11 +26,11 @@ function listRuntimeFiles(directory: string): string[] {
describe("supply domain boundary", () => { describe("supply domain boundary", () => {
it("does not reintroduce drainage-only operating language", () => { it("does not reintroduce drainage-only operating language", () => {
const violations = listRuntimeFiles(featuresRoot).flatMap((file) => { const violations = listRuntimeFiles(runtimeRoot).flatMap((file) => {
const source = readFileSync(file, "utf8"); const source = readFileSync(file, "utf8");
return forbiddenDrainageTerms return forbiddenDrainageTerms
.filter((term) => source.includes(term)) .filter((term) => source.includes(term))
.map((term) => `${file.replace(`${featuresRoot}/`, "")}: ${term}`); .map((term) => `${file.replace(`${runtimeRoot}/`, "")}: ${term}`);
}); });
expect(violations).toEqual([]); expect(violations).toEqual([]);