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
## Project Structure
## Scope
This is a Vite React single-page WebGIS Agent workbench. Runtime TypeScript lives
under `src/`.
This repository contains the Vite and React single-page frontend for the
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.
- `src/features/agent/`: controlled Agent UI protocol, schemas, renderer, and views.
- `src/features/map/`: WebGIS map shell and future MapLibre/deck.gl integrations.
- `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.
Keep this project browser-first. Do not add Next.js APIs, `"use client"`
directives, or general backend-for-frontend routes. Agent reasoning and tool
execution belong in `next-tjwater-agent`.
## 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
pnpm dev
pnpm build
pnpm typecheck
pnpm lint
pnpm test
pnpm test:browser
Do not recreate a catch-all `src/lib/` directory. Put feature-independent
helpers in `src/shared/`; put supply-network and GeoServer logic in the
owning `workbench` module.
Do not create empty placeholder directories. Add a directory with its first
source file.
## 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
module boundaries. Keep Agent dynamic UI rendering schema-driven: Agent output
must be validated before it reaches React components.
Use a feature's `index.ts` as its public API for cross-feature imports. Add an
intentional public export when another feature needs a symbol. Use relative
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
belongs in the Agent service; the local `server/` exception is limited to the
same-origin Edge TTS network adapter.
## Component and Module Design
- 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",
"private": true,
"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.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 }) => {
+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 { AppProviders } from "./providers";
@@ -1,9 +1,7 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { cn } from "@/shared/ui/cn";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
@@ -1,5 +1,3 @@
"use client";
import {
Activity,
CheckCircle2,
@@ -23,7 +21,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
@@ -48,7 +46,7 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
@@ -1,10 +1,8 @@
"use client";
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
@@ -1,5 +1,3 @@
"use client";
import {
CheckCircle2,
ChevronDown,
@@ -13,7 +11,7 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/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 {
agentEase,
@@ -1,5 +1,3 @@
"use client";
import type { Variants } from "motion/react";
export const AGENT_MOTION_DURATION_MS = 180;
@@ -1,5 +1,3 @@
"use client";
import {
ClipboardCheck,
Crosshair,
@@ -8,7 +6,7 @@ import {
Route
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type {
AgentChatMessage
} from "../types";
@@ -1,7 +1,7 @@
import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
const LazyPersona = lazy(async () => {
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 { AnimatePresence, MotionConfig, motion } from "motion/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 type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
@@ -1,6 +1,4 @@
"use client";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message";
type StreamingTokenResponseProps = {
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
splitSpeechTextIntoChunks,
+42 -5
View File
@@ -1,15 +1,56 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
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 {
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 {
applyPermissionResponse,
applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry,
toTrustedMapAction,
type TrustedMapAction,
type UIEnvelope,
type UIEnvelopePayload,
type UIRegistry,
type UISurface
} from "./ui-envelope";
export type {
AgentApprovalMode,
AgentChatMessage,
@@ -28,8 +69,4 @@ export type {
AgentTodoUpdate,
AgentUiResult
} from "./types";
export type {
SafeChartData,
SafeChartSeries,
SafeChartSpec
} from "./chart-data";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapPanelSection
@@ -1,8 +1,6 @@
"use client";
import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
@@ -1,6 +1,6 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
Tooltip,
TooltipContent,
@@ -1,5 +1,5 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,5 @@
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 { MapLayerControlItem } from "./layer-control";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapModeButton,
@@ -1,8 +1,6 @@
"use client";
import type { ComponentProps } from "react";
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 { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
@@ -1,8 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
@@ -1,8 +1,6 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
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";
type ScaleLineState = {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
Tooltip,
TooltipContent,
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react";
@@ -9,7 +7,7 @@ import {
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_CONTROL_HOVER_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 { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type {
ScheduledConditionRecord
} from "@/features/workbench/types";
} from "../types";
import {
createVisibleTickerCards,
type TickerStackCard
@@ -1,7 +1,7 @@
import type {
ScheduledConditionRiskLevel,
ScheduledConditionStatus
} from "@/features/workbench/types";
} from "../types";
export function getConditionReportRiskLevel(
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 { useMemo } from "react";
import type { DetailFeature } from "../types";
@@ -1,14 +1,12 @@
"use client";
import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/lib/utils";
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
showMapNotice
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import {
@@ -16,14 +14,14 @@ import {
type FeaturePanelBadgeTone
} from "../utils/feature-properties";
import {
ConduitFeatureIcon,
PipeFeatureIcon,
JunctionFeatureIcon,
PumpFeatureIcon,
ReservoirFeatureIcon,
ScadaFeatureIcon,
ValveFeatureIcon,
type DrainageFeatureIconProps
} from "./drainage-feature-icons";
type SupplyFeatureIconProps
} from "./supply-feature-icons";
import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = {
@@ -34,14 +32,14 @@ type FeaturePopoverProps = {
const FEATURE_LAYER_META: Record<
WaterNetworkSourceId,
{
icon: ComponentType<DrainageFeatureIconProps>;
icon: ComponentType<SupplyFeatureIconProps>;
label: string;
headerClassName: string;
labelClassName: 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" },
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" },
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
type FeaturePropertyListProps = {
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
@@ -1,5 +1,3 @@
"use client";
import {
AlertTriangle,
Bot,
@@ -20,13 +18,13 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/shared/ui/cn";
import { showMapNotice } from "@/features/map/core";
import {
getRunningWorkflowDefinition,
getRunningWorkflowState,
getWorkflowStepStatus
} from "@/features/workbench/data/running-workflows";
} from "../data/running-workflows";
import type {
ScheduledConditionAnalysisInsight,
ScheduledConditionItem,
@@ -34,7 +32,7 @@ import type {
ScheduledConditionRecord,
ScheduledConditionRiskLevel,
ScheduledWorkOrderItem
} from "@/features/workbench/types";
} from "../types";
import {
formatDuration,
formatTime,
@@ -10,7 +10,7 @@ import type {
ScheduledConditionStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "@/features/workbench/types";
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
@@ -1,5 +1,3 @@
"use client";
import {
CalendarClock,
ChevronDown,
@@ -10,12 +8,11 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
import { cn } from "@/lib/utils";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
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 {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationShareAction
} from "@/features/map/core/components/annotation-panel";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import {
MapActionRow,
MapControlPanel,
MapDrawToolbar,
MapLayerPanel,
MapLegendList,
MapMetricTile,
MapPanelSection
} from "@/features/map/core/components/control-panel";
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
MapMeasurePanel,
MapPanelSection,
type BaseLayerOption,
type MapAnnotationItem,
type MapAnnotationShareAction,
type MapLayerControlItem,
type MapLegendItem
} from "@/features/map/core";
import {
type WorkbenchDrawingAnnotation,
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 {
Activity,
Bell,
@@ -24,8 +22,8 @@ import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import {
Box,
@@ -13,8 +11,8 @@ import {
} from "lucide-react";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
import {
@@ -2,7 +2,7 @@ import type {
ScheduledConditionRecord,
ScheduledConditionStatus,
ScheduledConditionTaskId
} from "@/features/workbench/types";
} from "../types";
export type RunningWorkflowStepDefinition = {
id: string;
@@ -1,21 +1,19 @@
import type { UIMessage } from "ai";
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 {
applyPermissionResponse,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "@/features/agent/session-state";
upsertQuestion,
type AgentChatMessage,
type AgentPermissionReply,
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
} from "@/features/agent";
type AgentUiDataParts = {
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 { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -7,34 +5,28 @@ import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import {
createAgentApiClient,
type AgentSessionStreamEvent
} from "@/features/agent/api/client";
import { showMapNotice } from "@/features/map/core";
import {
FrontendActionExecutor,
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
fetchAgentBootstrap,
fetchAgentSessions
} from "@/features/agent/api/swr";
import {
fetchAgentSessions,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
} from "@/features/agent";
import {
applySessionStreamEvent,
createCompletedStreamRenderState,
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
@@ -1,5 +1,3 @@
"use client";
import "maplibre-gl/dist/maplibre-gl.css";
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 { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
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
};
}
+32 -290
View File
@@ -1,6 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
useCallback,
useEffect,
@@ -8,39 +5,33 @@ import {
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
type CSSProperties
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AgentCollapsedRail,
AgentCommandPanel,
AgentPersona,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult
} from "@/features/agent";
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import {
MapErrorNotice,
MapLoadingNotice,
MapScaleLine,
MapSourceStatusNotice,
MapToolbar,
MapZoom,
showMapNotice,
type MapToolbarItem,
type MapSourceStatus
} from "@/features/map/core/components/notice";
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";
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import {
ToolbarPanel,
type ExportViewPreset,
@@ -48,15 +39,12 @@ import {
type ToolbarToolId
} from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "./data/scheduled-conditions";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
useWorkbenchMeasurement,
@@ -65,20 +53,18 @@ import {
} from "./hooks/use-workbench-measurement";
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables,
getWorkbenchViewportLayout
getWorkbenchLayoutCssVariables
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_IDS,
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility,
createLayerControlItems,
getWorkbenchLayerIds
} from "./map/map-control-config";
@@ -89,49 +75,23 @@ import type {
ScheduledConditionRecord,
WorkbenchAlert
} from "./types";
import {
createScheduledConditionAlerts,
getScheduledConditionIdFromAlertId
} from "./utils/scheduled-condition-alerts";
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();
function formatHeaderDataTime(date: Date) {
const snappedMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function getMsUntilNextHeaderDataTimeTick(date: Date) {
const nextTick = new Date(date);
const nextMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
HEADER_DATA_TIME_STEP_MINUTES;
nextTick.setMinutes(nextMinutes, 0, 0);
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function MapWorkbenchPage() {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
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 [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
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 [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
@@ -153,9 +113,8 @@ export function MapWorkbenchPage() {
const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
useWorkbenchRuntimeData();
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -174,9 +133,6 @@ export function MapWorkbenchPage() {
const handleChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
setAgentPanelWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth)
);
};
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;
useEffect(() => {
@@ -751,7 +675,7 @@ export function MapWorkbenchPage() {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "drainage-network-map-config.json";
link.download = "supply-network-map-config.json";
document.body.appendChild(link);
link.click();
link.remove();
@@ -829,69 +753,6 @@ export function MapWorkbenchPage() {
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 (
<main
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}
/>
<div
ref={desktopAgentPanelRef}
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={agentPanelWidth === null ? undefined : { width: agentPanelWidth }}
>
{agent.panelOpen ? (
<>
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.panelCollapsing}
onCollapse={agent.collapsePanel}
/>
<div
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
<WorkbenchAgentPanels
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
mobileOpen={agent.mobileOpen}
mobilePanelCollapsing={agent.mobilePanelCollapsing}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
onExpand={agent.expandPanel}
viewportWidth={viewportWidth}
onCollapsePanel={agent.collapsePanel}
onCloseMobilePanel={agent.closeMobilePanel}
onExpandPanel={agent.expandPanel}
onOpenMobilePanel={agent.openMobilePanel}
/>
)}
</div>
{!devPanelOpen ? (
<div className="absolute right-2 top-24 z-20">
@@ -1017,22 +851,6 @@ export function MapWorkbenchPage() {
/>
) : 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 ? (
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
<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) {
if (preset === "4k") {
return 3840;
@@ -1145,67 +951,3 @@ function getExportPresetLabel(preset: ExportViewPreset) {
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) {
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 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 { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { GEOSERVER_WORKSPACE } from "../../../lib/config";
import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
import { GEOSERVER_WORKSPACE } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
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 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[] = [
{
id: "mapbox-light",
@@ -1,5 +1,5 @@
import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
@@ -139,70 +139,6 @@ const facilityIconSize = [
24, 0.56
] 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 = {
pipes: {
minzoom: 8.5,
+1 -1
View File
@@ -1,6 +1,6 @@
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";
export const WATER_NETWORK_GLOBAL_VIEW = {
+1 -1
View File
@@ -1,5 +1,5 @@
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[] = [
{
@@ -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 type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
import { getRunningWorkflowDefinition } from "../data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
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 {
Accordion,
AccordionContent,
@@ -7,7 +5,7 @@ import {
AccordionTrigger,
} from "@/shared/ui/accordion";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { Tool } from "ai";
import { BotIcon } from "lucide-react";
import type { ComponentProps } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button";
import {
Select,
@@ -8,7 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button";
import {
ButtonGroup,
@@ -11,7 +9,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RiveParameters } from "@rive-app/react-webgl2";
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
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 STATE_MACHINE = "default";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/shared/ui/button";
import {
Card,
@@ -15,7 +13,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/ui/collapsible";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, useContext, useMemo } from "react";
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import {
Command,
CommandEmpty,
@@ -39,7 +37,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
import {
CornerDownLeftIcon,
+1 -3
View File
@@ -1,6 +1,4 @@
"use client";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
+1 -3
View File
@@ -1,8 +1,6 @@
"use client";
import type { ComponentProps } 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";
export type SuggestionsProps = ComponentProps<"div">;
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Accordion = AccordionPrimitive.Root
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
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",
+1 -1
View File
@@ -1,7 +1,7 @@
import { Slot } from "@radix-ui/react-slot"
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"
const buttonGroupVariants = cva(
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
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",
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Card = React.forwardRef<
HTMLDivElement,
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
+1 -3
View File
@@ -1,11 +1,9 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
import { Dialog, DialogContent } from "@/shared/ui/dialog"
const Command = React.forwardRef<
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Dialog = DialogPrimitive.Root
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const DropdownMenu = DropdownMenuPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const HoverCard = HoverCardPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
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 { Input } from "@/shared/ui/input"
import { Textarea } from "@/shared/ui/textarea"
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
+1 -3
View File
@@ -1,10 +1,8 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Select = SelectPrimitive.Root
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
+1 -3
View File
@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
import { cn } from "@/shared/ui/cn"
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 { describe, expect, it } from "vitest";
const featuresRoot = join(process.cwd(), "src/features");
const runtimeRoot = join(process.cwd(), "src");
const forbiddenDrainageTerms = [
"排水管网",
"单位排水电耗",
"排水边界",
"距污水厂距离",
"关联检查井",
"管渠拓扑"
"管渠拓扑",
"drainage-network",
"DRAINAGE_",
"DrainageFeature"
] as const;
function listRuntimeFiles(directory: string): string[] {
@@ -23,11 +26,11 @@ function listRuntimeFiles(directory: string): string[] {
describe("supply domain boundary", () => {
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");
return forbiddenDrainageTerms
.filter((term) => source.includes(term))
.map((term) => `${file.replace(`${featuresRoot}/`, "")}: ${term}`);
.map((term) => `${file.replace(`${runtimeRoot}/`, "")}: ${term}`);
});
expect(violations).toEqual([]);