From b9bc9c3d3992dfbff9c58c0e7d331f72a2832cfb Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 22 Jul 2026 18:16:43 +0800 Subject: [PATCH] refactor: organize supply frontend structure --- AGENTS.md | 184 ++++++++-- package.json | 2 +- src/app/app.e2e.ts | 2 +- src/app/app.tsx | 2 +- .../agent/components/agent-collapsed-rail.tsx | 6 +- .../agent/components/agent-command-panel.tsx | 6 +- .../agent/components/agent-history-panel.tsx | 4 +- .../components/agent-message-details.tsx | 4 +- src/features/agent/components/agent-motion.ts | 2 - .../components/agent-operational-brief.tsx | 4 +- .../agent/components/agent-persona.tsx | 2 +- .../components/agent-ui-envelope-renderer.tsx | 4 +- .../components/streaming-token-response.tsx | 4 +- src/features/agent/hooks/use-agent-voice.ts | 2 - src/features/agent/index.ts | 47 ++- .../map/core/components/annotation-panel.tsx | 2 +- .../core/components/base-layers-control.tsx | 4 +- .../map/core/components/control-panel.tsx | 2 +- .../map/core/components/draw-toolbar.tsx | 4 +- .../map/core/components/layer-control.tsx | 2 +- .../map/core/components/layer-panel.tsx | 2 +- src/features/map/core/components/legend.tsx | 2 +- .../map/core/components/measure-panel.tsx | 2 +- .../map/core/components/notice-actions.tsx | 4 +- .../core/components/notice-presentation.tsx | 4 +- src/features/map/core/components/notice.tsx | 2 - .../map/core/components/scale-line.tsx | 4 +- src/features/map/core/components/toolbar.tsx | 4 +- src/features/map/core/components/zoom.tsx | 4 +- src/features/map/core/index.ts | 62 ++++ .../components/agent-task-ticker.tsx | 6 +- .../components/condition-report-risk.ts | 2 +- .../components/drainage-feature-icons.tsx | 113 ------ .../components/feature-insight-panel.tsx | 2 - .../workbench/components/feature-popover.tsx | 20 +- .../components/feature-property-list.tsx | 2 +- .../workbench/components/map-dev-panel.tsx | 4 +- .../scheduled-condition-detail-panel.tsx | 10 +- .../scheduled-condition-feed-utils.ts | 2 +- .../components/scheduled-condition-feed.tsx | 13 +- .../components/supply-feature-icons.tsx | 93 +++++ .../workbench/components/toolbar-panel.tsx | 23 +- .../components/workbench-agent-panels.tsx | 118 +++++++ .../components/workbench-top-bar-menus.tsx | 6 +- .../components/workbench-top-bar.tsx | 6 +- .../workbench/data/running-workflows.ts | 2 +- .../hooks/agent-session-message-state.ts | 18 +- .../workbench/hooks/use-agent-panel-resize.ts | 95 +++++ .../workbench/hooks/use-map-interactions.ts | 2 - .../hooks/use-simulation-layer-visibility.ts | 2 - .../workbench/hooks/use-workbench-agent.ts | 36 +- .../workbench/hooks/use-workbench-drawing.ts | 2 - .../hooks/use-workbench-map-controller.ts | 2 - .../workbench/hooks/use-workbench-map.ts | 2 - .../hooks/use-workbench-measurement.ts | 2 - .../hooks/use-workbench-runtime-data.test.ts | 15 + .../hooks/use-workbench-runtime-data.ts | 71 ++++ src/features/workbench/map-workbench-page.tsx | 328 ++---------------- src/features/workbench/map/export-view.ts | 2 +- .../workbench/map/geoserver-config.ts} | 2 +- .../workbench/map/map-control-config.ts | 18 +- .../workbench/map/map-feature-query.ts | 2 +- .../workbench/map/map-layer-visuals.ts | 64 ---- src/features/workbench/map/sources.ts | 2 +- src/features/workbench/map/toolbar-config.ts | 2 +- .../utils/scheduled-condition-alerts.ts | 36 ++ .../utils/scheduled-condition-prompts.ts | 4 +- src/lib/utils.ts | 6 - src/shared/ai-elements/agent.tsx | 4 +- src/shared/ai-elements/code-block.tsx | 4 +- src/shared/ai-elements/conversation.tsx | 4 +- src/shared/ai-elements/message.tsx | 4 +- src/shared/ai-elements/persona.tsx | 2 +- src/shared/ai-elements/plan.tsx | 4 +- src/shared/ai-elements/prompt-input.tsx | 4 +- src/shared/ai-elements/shimmer.tsx | 4 +- src/shared/ai-elements/suggestion.tsx | 4 +- src/shared/ui/accordion.tsx | 4 +- src/shared/ui/badge.tsx | 2 +- src/shared/ui/button-group.tsx | 2 +- src/shared/ui/button.tsx | 2 +- src/shared/ui/card.tsx | 2 +- src/shared/ui/collapsible.tsx | 2 - src/shared/ui/command.tsx | 4 +- src/shared/ui/dialog.tsx | 4 +- src/shared/ui/dropdown-menu.tsx | 4 +- src/shared/ui/hover-card.tsx | 4 +- src/shared/ui/input-group.tsx | 4 +- src/shared/ui/input.tsx | 2 +- src/shared/ui/select.tsx | 4 +- src/shared/ui/separator.tsx | 4 +- src/shared/ui/spinner.tsx | 2 +- src/shared/ui/textarea.tsx | 2 +- src/shared/ui/tooltip.tsx | 4 +- src/test/module-boundaries.test.ts | 51 +++ src/test/supply-domain.test.ts | 11 +- 96 files changed, 917 insertions(+), 745 deletions(-) create mode 100644 src/features/map/core/index.ts delete mode 100644 src/features/workbench/components/drainage-feature-icons.tsx create mode 100644 src/features/workbench/components/supply-feature-icons.tsx create mode 100644 src/features/workbench/components/workbench-agent-panels.tsx create mode 100644 src/features/workbench/hooks/use-agent-panel-resize.ts create mode 100644 src/features/workbench/hooks/use-workbench-runtime-data.test.ts create mode 100644 src/features/workbench/hooks/use-workbench-runtime-data.ts rename src/{lib/config.ts => features/workbench/map/geoserver-config.ts} (82%) create mode 100644 src/features/workbench/utils/scheduled-condition-alerts.ts delete mode 100644 src/lib/utils.ts create mode 100644 src/test/module-boundaries.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8d25257..6c0ed0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/package.json b/package.json index dc6c7fe..ab397d1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "tjwater-webgis-frontend", + "name": "next-tjwater-frontend", "version": "0.1.0", "private": true, "type": "module", diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index dbd6bc0..f8aba22 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -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 }) => { diff --git a/src/app/app.tsx b/src/app/app.tsx index 64985c2..609e42e 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -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"; diff --git a/src/features/agent/components/agent-collapsed-rail.tsx b/src/features/agent/components/agent-collapsed-rail.tsx index b06774a..572da45 100644 --- a/src/features/agent/components/agent-collapsed-rail.tsx +++ b/src/features/agent/components/agent-collapsed-rail.tsx @@ -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 = { diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index 535571b..a27861a 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -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 { diff --git a/src/features/agent/components/agent-history-panel.tsx b/src/features/agent/components/agent-history-panel.tsx index 7a9767c..cd2daac 100644 --- a/src/features/agent/components/agent-history-panel.tsx +++ b/src/features/agent/components/agent-history-panel.tsx @@ -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, diff --git a/src/features/agent/components/agent-message-details.tsx b/src/features/agent/components/agent-message-details.tsx index 2ac86d4..c8c6088 100644 --- a/src/features/agent/components/agent-message-details.tsx +++ b/src/features/agent/components/agent-message-details.tsx @@ -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, diff --git a/src/features/agent/components/agent-motion.ts b/src/features/agent/components/agent-motion.ts index d99c58e..d4379f3 100644 --- a/src/features/agent/components/agent-motion.ts +++ b/src/features/agent/components/agent-motion.ts @@ -1,5 +1,3 @@ -"use client"; - import type { Variants } from "motion/react"; export const AGENT_MOTION_DURATION_MS = 180; diff --git a/src/features/agent/components/agent-operational-brief.tsx b/src/features/agent/components/agent-operational-brief.tsx index 2121b74..c56c559 100644 --- a/src/features/agent/components/agent-operational-brief.tsx +++ b/src/features/agent/components/agent-operational-brief.tsx @@ -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"; diff --git a/src/features/agent/components/agent-persona.tsx b/src/features/agent/components/agent-persona.tsx index e9ddb97..44c1157 100644 --- a/src/features/agent/components/agent-persona.tsx +++ b/src/features/agent/components/agent-persona.tsx @@ -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"); diff --git a/src/features/agent/components/agent-ui-envelope-renderer.tsx b/src/features/agent/components/agent-ui-envelope-renderer.tsx index 99f1c5d..be187f2 100644 --- a/src/features/agent/components/agent-ui-envelope-renderer.tsx +++ b/src/features/agent/components/agent-ui-envelope-renderer.tsx @@ -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"; diff --git a/src/features/agent/components/streaming-token-response.tsx b/src/features/agent/components/streaming-token-response.tsx index 8d92e65..348f094 100644 --- a/src/features/agent/components/streaming-token-response.tsx +++ b/src/features/agent/components/streaming-token-response.tsx @@ -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 = { diff --git a/src/features/agent/hooks/use-agent-voice.ts b/src/features/agent/hooks/use-agent-voice.ts index 53962b3..b41dc45 100644 --- a/src/features/agent/hooks/use-agent-voice.ts +++ b/src/features/agent/hooks/use-agent-voice.ts @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { splitSpeechTextIntoChunks, diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts index a00e551..f14bf04 100644 --- a/src/features/agent/index.ts +++ b/src/features/agent/index.ts @@ -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"; diff --git a/src/features/map/core/components/annotation-panel.tsx b/src/features/map/core/components/annotation-panel.tsx index bfed99d..cd63467 100644 --- a/src/features/map/core/components/annotation-panel.tsx +++ b/src/features/map/core/components/annotation-panel.tsx @@ -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 diff --git a/src/features/map/core/components/base-layers-control.tsx b/src/features/map/core/components/base-layers-control.tsx index a2757b3..f439b01 100644 --- a/src/features/map/core/components/base-layers-control.tsx +++ b/src/features/map/core/components/base-layers-control.tsx @@ -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, diff --git a/src/features/map/core/components/control-panel.tsx b/src/features/map/core/components/control-panel.tsx index 9b4e247..bda74fc 100644 --- a/src/features/map/core/components/control-panel.tsx +++ b/src/features/map/core/components/control-panel.tsx @@ -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, diff --git a/src/features/map/core/components/draw-toolbar.tsx b/src/features/map/core/components/draw-toolbar.tsx index d25ebf8..f4bb8f4 100644 --- a/src/features/map/core/components/draw-toolbar.tsx +++ b/src/features/map/core/components/draw-toolbar.tsx @@ -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, diff --git a/src/features/map/core/components/layer-control.tsx b/src/features/map/core/components/layer-control.tsx index 4b3f5d6..4eb6b1c 100644 --- a/src/features/map/core/components/layer-control.tsx +++ b/src/features/map/core/components/layer-control.tsx @@ -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, diff --git a/src/features/map/core/components/layer-panel.tsx b/src/features/map/core/components/layer-panel.tsx index 534baf2..0d24510 100644 --- a/src/features/map/core/components/layer-panel.tsx +++ b/src/features/map/core/components/layer-panel.tsx @@ -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 { diff --git a/src/features/map/core/components/legend.tsx b/src/features/map/core/components/legend.tsx index 5f70cfd..08d08e6 100644 --- a/src/features/map/core/components/legend.tsx +++ b/src/features/map/core/components/legend.tsx @@ -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, diff --git a/src/features/map/core/components/measure-panel.tsx b/src/features/map/core/components/measure-panel.tsx index 9993699..a7a4813 100644 --- a/src/features/map/core/components/measure-panel.tsx +++ b/src/features/map/core/components/measure-panel.tsx @@ -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, diff --git a/src/features/map/core/components/notice-actions.tsx b/src/features/map/core/components/notice-actions.tsx index 548302e..a238753 100644 --- a/src/features/map/core/components/notice-actions.tsx +++ b/src/features/map/core/components/notice-actions.tsx @@ -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"; diff --git a/src/features/map/core/components/notice-presentation.tsx b/src/features/map/core/components/notice-presentation.tsx index 97a2b7c..ea20822 100644 --- a/src/features/map/core/components/notice-presentation.tsx +++ b/src/features/map/core/components/notice-presentation.tsx @@ -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, diff --git a/src/features/map/core/components/notice.tsx b/src/features/map/core/components/notice.tsx index a7e7a72..12f05f4 100644 --- a/src/features/map/core/components/notice.tsx +++ b/src/features/map/core/components/notice.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect } from "react"; import { Toaster } from "sonner"; import { dismissMapNotice, showMapNotice } from "./notice-actions"; diff --git a/src/features/map/core/components/scale-line.tsx b/src/features/map/core/components/scale-line.tsx index 7c006ca..5b24dea 100644 --- a/src/features/map/core/components/scale-line.tsx +++ b/src/features/map/core/components/scale-line.tsx @@ -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 = { diff --git a/src/features/map/core/components/toolbar.tsx b/src/features/map/core/components/toolbar.tsx index 9b46d30..debd581 100644 --- a/src/features/map/core/components/toolbar.tsx +++ b/src/features/map/core/components/toolbar.tsx @@ -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, diff --git a/src/features/map/core/components/zoom.tsx b/src/features/map/core/components/zoom.tsx index a5fe0b6..0b4e9b6 100644 --- a/src/features/map/core/components/zoom.tsx +++ b/src/features/map/core/components/zoom.tsx @@ -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, diff --git a/src/features/map/core/index.ts b/src/features/map/core/index.ts new file mode 100644 index 0000000..1eb60a0 --- /dev/null +++ b/src/features/map/core/index.ts @@ -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"; diff --git a/src/features/workbench/components/agent-task-ticker.tsx b/src/features/workbench/components/agent-task-ticker.tsx index 727250a..43335aa 100644 --- a/src/features/workbench/components/agent-task-ticker.tsx +++ b/src/features/workbench/components/agent-task-ticker.tsx @@ -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 diff --git a/src/features/workbench/components/condition-report-risk.ts b/src/features/workbench/components/condition-report-risk.ts index e01779f..771a1ee 100644 --- a/src/features/workbench/components/condition-report-risk.ts +++ b/src/features/workbench/components/condition-report-risk.ts @@ -1,7 +1,7 @@ import type { ScheduledConditionRiskLevel, ScheduledConditionStatus -} from "@/features/workbench/types"; +} from "../types"; export function getConditionReportRiskLevel( status: ScheduledConditionStatus, diff --git a/src/features/workbench/components/drainage-feature-icons.tsx b/src/features/workbench/components/drainage-feature-icons.tsx deleted file mode 100644 index 9090a8a..0000000 --- a/src/features/workbench/components/drainage-feature-icons.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { ReactNode, SVGProps } from "react"; - -export type DrainageFeatureIconProps = SVGProps & { - size?: number; -}; - -type DrainageIconFrameProps = DrainageFeatureIconProps & { - children: ReactNode; -}; - -function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) { - return ( - - {children} - - ); -} - -export function ConduitFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - ); -} - -export function JunctionFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - ); -} - -export function ValveFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - - ); -} - -export function ReservoirFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - - ); -} - -export function ScadaFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - - ); -} - -export function OrificeFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - ); -} - -export function PumpFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - - - ); -} - -export function OutfallFeatureIcon(props: DrainageFeatureIconProps) { - return ( - - - - - - ); -} diff --git a/src/features/workbench/components/feature-insight-panel.tsx b/src/features/workbench/components/feature-insight-panel.tsx index 4899654..2c14468 100644 --- a/src/features/workbench/components/feature-insight-panel.tsx +++ b/src/features/workbench/components/feature-insight-panel.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Target, TriangleAlert, X } from "lucide-react"; import { useMemo } from "react"; import type { DetailFeature } from "../types"; diff --git a/src/features/workbench/components/feature-popover.tsx b/src/features/workbench/components/feature-popover.tsx index 79e4ebb..ca59f2f 100644 --- a/src/features/workbench/components/feature-popover.tsx +++ b/src/features/workbench/components/feature-popover.tsx @@ -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; + icon: ComponentType; 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" }, diff --git a/src/features/workbench/components/feature-property-list.tsx b/src/features/workbench/components/feature-property-list.tsx index fcc2843..1bf2eeb 100644 --- a/src/features/workbench/components/feature-property-list.tsx +++ b/src/features/workbench/components/feature-property-list.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/shared/ui/cn"; import type { LocalizedFeatureProperty } from "../utils/feature-properties"; type FeaturePropertyListProps = { diff --git a/src/features/workbench/components/map-dev-panel.tsx b/src/features/workbench/components/map-dev-panel.tsx index 3f1eade..7bb8a8b 100644 --- a/src/features/workbench/components/map-dev-panel.tsx +++ b/src/features/workbench/components/map-dev-panel.tsx @@ -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, diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 00d660d..073aab5 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -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, diff --git a/src/features/workbench/components/scheduled-condition-feed-utils.ts b/src/features/workbench/components/scheduled-condition-feed-utils.ts index 3602f31..898276a 100644 --- a/src/features/workbench/components/scheduled-condition-feed-utils.ts +++ b/src/features/workbench/components/scheduled-condition-feed-utils.ts @@ -10,7 +10,7 @@ import type { ScheduledConditionStatus, ScheduledWorkOrderItem, ScheduledWorkOrderStage -} from "@/features/workbench/types"; +} from "../types"; export const CONDITION_FILTER_ALL = "__all__"; diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index 17bdb46..bca021c 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -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, diff --git a/src/features/workbench/components/supply-feature-icons.tsx b/src/features/workbench/components/supply-feature-icons.tsx new file mode 100644 index 0000000..a438eef --- /dev/null +++ b/src/features/workbench/components/supply-feature-icons.tsx @@ -0,0 +1,93 @@ +import type { ReactNode, SVGProps } from "react"; + +export type SupplyFeatureIconProps = SVGProps & { + size?: number; +}; + +type SupplyIconFrameProps = SupplyFeatureIconProps & { + children: ReactNode; +}; + +function SupplyIconFrame({ children, size = 20, ...props }: SupplyIconFrameProps) { + return ( + + {children} + + ); +} + +export function PipeFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + ); +} + +export function JunctionFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + ); +} + +export function ValveFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + + ); +} + +export function ReservoirFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + + ); +} + +export function ScadaFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + + ); +} + +export function PumpFeatureIcon(props: SupplyFeatureIconProps) { + return ( + + + + + + + + ); +} diff --git a/src/features/workbench/components/toolbar-panel.tsx b/src/features/workbench/components/toolbar-panel.tsx index e4c96fe..7dfcc89 100644 --- a/src/features/workbench/components/toolbar-panel.tsx +++ b/src/features/workbench/components/toolbar-panel.tsx @@ -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 diff --git a/src/features/workbench/components/workbench-agent-panels.tsx b/src/features/workbench/components/workbench-agent-panels.tsx new file mode 100644 index 0000000..29e5de2 --- /dev/null +++ b/src/features/workbench/components/workbench-agent-panels.tsx @@ -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, "collapsing" | "onCollapse">; + +type WorkbenchAgentPanelsProps = { + panelProps: AgentPanelProps; + panelOpen: boolean; + panelCollapsing: boolean; + mobileOpen: boolean; + mobilePanelCollapsing: boolean; + personaState: ComponentProps["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 ( + <> +
+ {panelOpen ? ( + <> + +
span]:bg-blue-500 [&>span]:opacity-100" + )} + role="separator" + tabIndex={0} + title="拖拽调整 Agent 面板宽度" + onKeyDown={resize.handleKeyDown} + onPointerDown={resize.handlePointerDown} + > + +
+ + ) : ( + + )} +
+ +
+ {mobileOpen ? ( +
+ +
+ ) : null} +
+ + {!mobileOpen ? ( +
+ +
+ ) : null} + + ); +} diff --git a/src/features/workbench/components/workbench-top-bar-menus.tsx b/src/features/workbench/components/workbench-top-bar-menus.tsx index 6e1efc9..a5349e9 100644 --- a/src/features/workbench/components/workbench-top-bar-menus.tsx +++ b/src/features/workbench/components/workbench-top-bar-menus.tsx @@ -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, diff --git a/src/features/workbench/components/workbench-top-bar.tsx b/src/features/workbench/components/workbench-top-bar.tsx index 37a7594..aa21937 100644 --- a/src/features/workbench/components/workbench-top-bar.tsx +++ b/src/features/workbench/components/workbench-top-bar.tsx @@ -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 { diff --git a/src/features/workbench/data/running-workflows.ts b/src/features/workbench/data/running-workflows.ts index c6d86d7..1591ba0 100644 --- a/src/features/workbench/data/running-workflows.ts +++ b/src/features/workbench/data/running-workflows.ts @@ -2,7 +2,7 @@ import type { ScheduledConditionRecord, ScheduledConditionStatus, ScheduledConditionTaskId -} from "@/features/workbench/types"; +} from "../types"; export type RunningWorkflowStepDefinition = { id: string; diff --git a/src/features/workbench/hooks/agent-session-message-state.ts b/src/features/workbench/hooks/agent-session-message-state.ts index 1278a2b..b589c05 100644 --- a/src/features/workbench/hooks/agent-session-message-state.ts +++ b/src/features/workbench/hooks/agent-session-message-state.ts @@ -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; diff --git a/src/features/workbench/hooks/use-agent-panel-resize.ts b/src/features/workbench/hooks/use-agent-panel-resize.ts new file mode 100644 index 0000000..057c70d --- /dev/null +++ b/src/features/workbench/hooks/use-agent-panel-resize.ts @@ -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(null); + const resizeLeftRef = useRef(0); + const cleanupRef = useRef<(() => void) | null>(null); + const [width, setWidth] = useState(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) => { + 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) => { + 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 + }; +} diff --git a/src/features/workbench/hooks/use-map-interactions.ts b/src/features/workbench/hooks/use-map-interactions.ts index f3f9803..e1585cc 100644 --- a/src/features/workbench/hooks/use-map-interactions.ts +++ b/src/features/workbench/hooks/use-map-interactions.ts @@ -1,5 +1,3 @@ -"use client"; - import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl"; import type { RefObject } from "react"; import { useEffect } from "react"; diff --git a/src/features/workbench/hooks/use-simulation-layer-visibility.ts b/src/features/workbench/hooks/use-simulation-layer-visibility.ts index df54259..31059f0 100644 --- a/src/features/workbench/hooks/use-simulation-layer-visibility.ts +++ b/src/features/workbench/hooks/use-simulation-layer-visibility.ts @@ -1,5 +1,3 @@ -"use client"; - import type { Map as MapLibreMap } from "maplibre-gl"; import type { RefObject } from "react"; import { useEffect } from "react"; diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index 0d4fd7a..9be518b 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -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, diff --git a/src/features/workbench/hooks/use-workbench-drawing.ts b/src/features/workbench/hooks/use-workbench-drawing.ts index afe74c5..2b88e54 100644 --- a/src/features/workbench/hooks/use-workbench-drawing.ts +++ b/src/features/workbench/hooks/use-workbench-drawing.ts @@ -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 { diff --git a/src/features/workbench/hooks/use-workbench-map-controller.ts b/src/features/workbench/hooks/use-workbench-map-controller.ts index 9ca63f4..6a52e28 100644 --- a/src/features/workbench/hooks/use-workbench-map-controller.ts +++ b/src/features/workbench/hooks/use-workbench-map-controller.ts @@ -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"; diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts index e27e374..450f998 100644 --- a/src/features/workbench/hooks/use-workbench-map.ts +++ b/src/features/workbench/hooks/use-workbench-map.ts @@ -1,5 +1,3 @@ -"use client"; - import "maplibre-gl/dist/maplibre-gl.css"; import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl"; diff --git a/src/features/workbench/hooks/use-workbench-measurement.ts b/src/features/workbench/hooks/use-workbench-measurement.ts index f477a70..f6d8383 100644 --- a/src/features/workbench/hooks/use-workbench-measurement.ts +++ b/src/features/workbench/hooks/use-workbench-measurement.ts @@ -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 { diff --git a/src/features/workbench/hooks/use-workbench-runtime-data.test.ts b/src/features/workbench/hooks/use-workbench-runtime-data.test.ts new file mode 100644 index 0000000..03d6563 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-runtime-data.test.ts @@ -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); + }); +}); diff --git a/src/features/workbench/hooks/use-workbench-runtime-data.ts b/src/features/workbench/hooks/use-workbench-runtime-data.ts new file mode 100644 index 0000000..7e279dd --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-runtime-data.ts @@ -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([]); + 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 + }; +} diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 3bfb45c..407fa8f 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -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(null); - const desktopAgentPanelRef = useRef(null); - const agentPanelResizeLeftRef = useRef(0); - const agentPanelResizeCleanupRef = useRef<(() => void) | null>(null); const [detailFeature, setDetailFeature] = useState(null); const [devPanelOpen, setDevPanelOpen] = useState(false); const [impactVisible, setImpactVisible] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false); const [viewportWidth, setViewportWidth] = useState(WORKBENCH_LAYOUT.desktopMinWidth); - const [agentPanelWidth, setAgentPanelWidth] = useState(null); - const [agentPanelResizing, setAgentPanelResizing] = useState(false); const [activeToolId, setActiveToolId] = useState(null); const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedMounted, setConditionFeedMounted] = useState(true); @@ -153,9 +113,8 @@ export function MapWorkbenchPage() { const [layerVisibility, setLayerVisibility] = useState>(INITIAL_LAYER_VISIBILITY); const [agentUiResults, setAgentUiResults] = useState([]); - const [scheduledConditions, setScheduledConditions] = useState([]); - 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) => { - 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) => { - 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 (
-
- {agent.panelOpen ? ( - <> - -
span]:bg-blue-500 [&>span]:opacity-100" - )} - role="separator" - tabIndex={0} - title="拖拽调整 Agent 面板宽度" - onKeyDown={handleAgentPanelResizeKeyDown} - onPointerDown={handleAgentPanelResizeStart} - > - -
- - ) : ( - - )} -
+ {!devPanelOpen ? (
@@ -1017,22 +851,6 @@ export function MapWorkbenchPage() { /> ) : null} -
- {agent.mobileOpen ? ( -
- -
- ) : null} -
- - {!agent.mobileOpen ? ( - - ) : null} - {!agent.mobileOpen ? (
{ - 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["state"]; - onOpen: () => void; -}) { - return ( -
- -
- ); -} diff --git a/src/features/workbench/map/export-view.ts b/src/features/workbench/map/export-view.ts index a4c1fbb..049b939 100644 --- a/src/features/workbench/map/export-view.ts +++ b/src/features/workbench/map/export-view.ts @@ -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`; } diff --git a/src/lib/config.ts b/src/features/workbench/map/geoserver-config.ts similarity index 82% rename from src/lib/config.ts rename to src/features/workbench/map/geoserver-config.ts index c4971ba..bb41bd5 100644 --- a/src/lib/config.ts +++ b/src/features/workbench/map/geoserver-config.ts @@ -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, ""); diff --git a/src/features/workbench/map/map-control-config.ts b/src/features/workbench/map/map-control-config.ts index 08c7adc..c46f386 100644 --- a/src/features/workbench/map/map-control-config.ts +++ b/src/features/workbench/map/map-control-config.ts @@ -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", diff --git a/src/features/workbench/map/map-feature-query.ts b/src/features/workbench/map/map-feature-query.ts index ae5dcb9..ee73d23 100644 --- a/src/features/workbench/map/map-feature-query.ts +++ b/src/features/workbench/map/map-feature-query.ts @@ -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, diff --git a/src/features/workbench/map/map-layer-visuals.ts b/src/features/workbench/map/map-layer-visuals.ts index eedcd47..c392377 100644 --- a/src/features/workbench/map/map-layer-visuals.ts +++ b/src/features/workbench/map/map-layer-visuals.ts @@ -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, diff --git a/src/features/workbench/map/sources.ts b/src/features/workbench/map/sources.ts index bea004e..a2aac37 100644 --- a/src/features/workbench/map/sources.ts +++ b/src/features/workbench/map/sources.ts @@ -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 = { diff --git a/src/features/workbench/map/toolbar-config.ts b/src/features/workbench/map/toolbar-config.ts index bff9d75..ecc8d0e 100644 --- a/src/features/workbench/map/toolbar-config.ts +++ b/src/features/workbench/map/toolbar-config.ts @@ -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[] = [ { diff --git a/src/features/workbench/utils/scheduled-condition-alerts.ts b/src/features/workbench/utils/scheduled-condition-alerts.ts new file mode 100644 index 0000000..918920e --- /dev/null +++ b/src/features/workbench/utils/scheduled-condition-alerts.ts @@ -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; +} diff --git a/src/features/workbench/utils/scheduled-condition-prompts.ts b/src/features/workbench/utils/scheduled-condition-prompts.ts index a976533..a7fa2f8 100644 --- a/src/features/workbench/utils/scheduled-condition-prompts.ts +++ b/src/features/workbench/utils/scheduled-condition-prompts.ts @@ -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-"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts deleted file mode 100644 index a5ef193..0000000 --- a/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/src/shared/ai-elements/agent.tsx b/src/shared/ai-elements/agent.tsx index 2acd091..20f78f6 100644 --- a/src/shared/ai-elements/agent.tsx +++ b/src/shared/ai-elements/agent.tsx @@ -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"; diff --git a/src/shared/ai-elements/code-block.tsx b/src/shared/ai-elements/code-block.tsx index 0f0622e..3b7ff3a 100644 --- a/src/shared/ai-elements/code-block.tsx +++ b/src/shared/ai-elements/code-block.tsx @@ -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 { diff --git a/src/shared/ai-elements/conversation.tsx b/src/shared/ai-elements/conversation.tsx index cb7f87c..d69a9f1 100644 --- a/src/shared/ai-elements/conversation.tsx +++ b/src/shared/ai-elements/conversation.tsx @@ -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"; diff --git a/src/shared/ai-elements/message.tsx b/src/shared/ai-elements/message.tsx index 54ec7f7..37ae602 100644 --- a/src/shared/ai-elements/message.tsx +++ b/src/shared/ai-elements/message.tsx @@ -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"; diff --git a/src/shared/ai-elements/persona.tsx b/src/shared/ai-elements/persona.tsx index 8ab7f7a..924a58b 100644 --- a/src/shared/ai-elements/persona.tsx +++ b/src/shared/ai-elements/persona.tsx @@ -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"; diff --git a/src/shared/ai-elements/plan.tsx b/src/shared/ai-elements/plan.tsx index 03736e4..c32aafc 100644 --- a/src/shared/ai-elements/plan.tsx +++ b/src/shared/ai-elements/plan.tsx @@ -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"; diff --git a/src/shared/ai-elements/prompt-input.tsx b/src/shared/ai-elements/prompt-input.tsx index 476c9da..73c90a3 100644 --- a/src/shared/ai-elements/prompt-input.tsx +++ b/src/shared/ai-elements/prompt-input.tsx @@ -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, diff --git a/src/shared/ai-elements/shimmer.tsx b/src/shared/ai-elements/shimmer.tsx index 6b635d0..dc02d09 100644 --- a/src/shared/ai-elements/shimmer.tsx +++ b/src/shared/ai-elements/shimmer.tsx @@ -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"; diff --git a/src/shared/ai-elements/suggestion.tsx b/src/shared/ai-elements/suggestion.tsx index 20db863..2c4490d 100644 --- a/src/shared/ai-elements/suggestion.tsx +++ b/src/shared/ai-elements/suggestion.tsx @@ -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">; diff --git a/src/shared/ui/accordion.tsx b/src/shared/ui/accordion.tsx index 2f55a32..9132a50 100644 --- a/src/shared/ui/accordion.tsx +++ b/src/shared/ui/accordion.tsx @@ -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 diff --git a/src/shared/ui/badge.tsx b/src/shared/ui/badge.tsx index 01d870b..3c9cba8 100644 --- a/src/shared/ui/badge.tsx +++ b/src/shared/ui/badge.tsx @@ -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", diff --git a/src/shared/ui/button-group.tsx b/src/shared/ui/button-group.tsx index 1238aab..631b2db 100644 --- a/src/shared/ui/button-group.tsx +++ b/src/shared/ui/button-group.tsx @@ -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( diff --git a/src/shared/ui/button.tsx b/src/shared/ui/button.tsx index f847642..72b583f 100644 --- a/src/shared/ui/button.tsx +++ b/src/shared/ui/button.tsx @@ -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", diff --git a/src/shared/ui/card.tsx b/src/shared/ui/card.tsx index e7e3161..ddda72d 100644 --- a/src/shared/ui/card.tsx +++ b/src/shared/ui/card.tsx @@ -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, diff --git a/src/shared/ui/collapsible.tsx b/src/shared/ui/collapsible.tsx index 9fa4894..a23e7a2 100644 --- a/src/shared/ui/collapsible.tsx +++ b/src/shared/ui/collapsible.tsx @@ -1,5 +1,3 @@ -"use client" - import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" const Collapsible = CollapsiblePrimitive.Root diff --git a/src/shared/ui/command.tsx b/src/shared/ui/command.tsx index 0fa563c..ec9ac1c 100644 --- a/src/shared/ui/command.tsx +++ b/src/shared/ui/command.tsx @@ -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< diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx index 0757140..43084eb 100644 --- a/src/shared/ui/dialog.tsx +++ b/src/shared/ui/dialog.tsx @@ -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 diff --git a/src/shared/ui/dropdown-menu.tsx b/src/shared/ui/dropdown-menu.tsx index 7447403..2d00c8a 100644 --- a/src/shared/ui/dropdown-menu.tsx +++ b/src/shared/ui/dropdown-menu.tsx @@ -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 diff --git a/src/shared/ui/hover-card.tsx b/src/shared/ui/hover-card.tsx index 7f36500..f39d565 100644 --- a/src/shared/ui/hover-card.tsx +++ b/src/shared/ui/hover-card.tsx @@ -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 diff --git a/src/shared/ui/input-group.tsx b/src/shared/ui/input-group.tsx index a87d19b..3d4936b 100644 --- a/src/shared/ui/input-group.tsx +++ b/src/shared/ui/input-group.tsx @@ -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" diff --git a/src/shared/ui/input.tsx b/src/shared/ui/input.tsx index 6f3073a..089eadc 100644 --- a/src/shared/ui/input.tsx +++ b/src/shared/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/lib/utils" +import { cn } from "@/shared/ui/cn" const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { diff --git a/src/shared/ui/select.tsx b/src/shared/ui/select.tsx index b28ed70..6a4cb1f 100644 --- a/src/shared/ui/select.tsx +++ b/src/shared/ui/select.tsx @@ -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 diff --git a/src/shared/ui/separator.tsx b/src/shared/ui/separator.tsx index 12d81c4..1280d0d 100644 --- a/src/shared/ui/separator.tsx +++ b/src/shared/ui/separator.tsx @@ -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, diff --git a/src/shared/ui/spinner.tsx b/src/shared/ui/spinner.tsx index a70e713..20bddfc 100644 --- a/src/shared/ui/spinner.tsx +++ b/src/shared/ui/spinner.tsx @@ -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 ( diff --git a/src/shared/ui/textarea.tsx b/src/shared/ui/textarea.tsx index e0da62c..0c2352a 100644 --- a/src/shared/ui/textarea.tsx +++ b/src/shared/ui/textarea.tsx @@ -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, diff --git a/src/shared/ui/tooltip.tsx b/src/shared/ui/tooltip.tsx index 424f375..0a0dc7f 100644 --- a/src/shared/ui/tooltip.tsx +++ b/src/shared/ui/tooltip.tsx @@ -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 diff --git a/src/test/module-boundaries.test.ts b/src/test/module-boundaries.test.ts new file mode 100644 index 0000000..fc85dd1 --- /dev/null +++ b/src/test/module-boundaries.test.ts @@ -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); + }); +}); diff --git a/src/test/supply-domain.test.ts b/src/test/supply-domain.test.ts index d400597..291c40b 100644 --- a/src/test/supply-domain.test.ts +++ b/src/test/supply-domain.test.ts @@ -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([]);