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